@calmdownval/signal

A lightweight event dispatcher with async handler support.

Usage no npm install needed!

<script type="module">
  import calmdownvalSignal from 'https://cdn.skypack.dev/@calmdownval/signal';
</script>

README

Signal

A lightweight event dispatcher.

Installation

You can install this package using NPM or Yarn. It already contains its own typings and needs no additional dependencies to use with TypeScript.

# using NPM
npm install @calmdownval/signal

# using Yarn
yarn add @calmdownval/signal

Comparison with EventEmitter

Pros

  • ✅ supports async handlers and both serial or parallel invocation
  • ✅ written and compatible with TypeScript
  • ✅ tiny, without any dependencies (<2 kB)
  • ✅ does not rely on class inheritance or mixins
  • ✅ smoothly integrates with standard event emitters
  • ✅ does not rely on event name strings, which are harder to use with autocompletion or type-checking and can be a source of silly bugs due to typos

Cons

  • ❌ is non-standard and will involve some learning curve

Usage Guide

The library provides everything as named exports. Usually the best approach for good code readability is to import the entire namespace as Signal.

import * as Signal from '@calmdownval/signal';

Creating a Signal

To create a signal call the Signal.create function. You can pass an options object with the following properties:

  • async: boolean = false
    controls whether the signal should act with respect to promises returned by event handlers
  • parallel: boolean = false
    controls whether asynchronous handlers will run in parallel or in series, only has effect if async is set to true
  • backend: 'array' | 'es6map' = 'array'
    controls which data structure is used to hold the handler collection, see the Signal Backend section for more information

Internally this function only checks the async option and delegates execution to either Signal.createSync or Signal.createAsync.

// will invoke handlers synchronously in series
const syncSignal1 = Signal.createSync();
const syncSignal2 = Signal.create();

// will invoke handlers asynchronously, in series, one at a time
const asyncSignal1 = Signal.createAsync();
const asyncSignal2 = Signal.create({ async: true });

// will invoke handlers asynchronously, all at once, in parallel
const asyncSignal3 = Signal.createAsync({ parallel: true });
const asyncSignal4 = Signal.create({
  async: true,
  parallel: true
});

// will use ES6 Map to hold its list of handlers
const mapSignal = Signal.createSync({ backend: 'es6map' });

Adding Handlers

To add a handler use the Signal.on function. The first argument is always a signal instance, the second is the handler to add.

Signal.on(mySignal, () => console.log('foo'));

Handlers will be invoked every time the signal is triggered. You can also set the once flag to only invoke a handler once and then have it automatically removed from the handler list.

Signal.on(mySignal, myHandler, { once: true });

// shorter version using the .once util
Signal.once(mySignal, myHandler);

When a handler is first added with the once flag set and later added again normally, assuming the signal wasn't triggered between these operation, the once flag will be unset.

const mySignal = Signal.create();
const onTrigger = () => console.log('bar');

Signal.once(mySignal, onTrigger);
Signal.on(mySignal, onTrigger);

// will print 'bar'
mySignal();

// will print 'bar' again
mySignal();

Without involving the once flag, calling on multiple times with the same handler has no effect. Handlers are kept in a set (i.e. there are never any duplicates in the handler list).

Removing Handlers

To remove a handler (regardless of the once option), use the Signal.off function.

If no specific handler is provided as the second argument the .off function will remove all handlers registered for the signal.

// will remove the first found occurrence of myHandler
Signal.off(s1, myHandler);

// will remove all registered handlers
Signal.off(s1);

The .off function will return a boolean indicating whether the operation removed any handlers.

Triggering a Signal

Each signal instance is actually a function. Triggering it is as simple as adding a pair of brackets! You can pass any data as the first argument to a signal, it will be forwarded to each handler. Typically this will be an event object with additional information.

Synchronous signals do not return any value (void), asynchronous return a Promise<void>.

mySignal(123);

Synchronous signals will always invoke handlers in series. If any one of them throws the execution immediately stops. It is the caller's responsibility to handle thrown exceptions:

try {
  mySignal();
}
catch (ex) {
  console.error('one of the handlers threw an exception', ex);
}

You can add async handlers to synchronous signals, but they will be executed in a fire-and-forget fashion. This may be desirable in some cases, but keep in mind that any potential promise rejections will not be handled!

Checking for Handlers

To check whether there are any handlers attached to a signal, you can read the hasHandlers property. This is especially useful when computationally expensive operations are needed for event data creation. Checking whether there are any handlers beforehand can avoid such operations when they're not necessary.

if (mySignal.hasHandlers) {
  mySignal({
    value: heavyFn()
  });
}

You can also use the utility function lazy to trigger a signal. It accepts a factory function for event data that gets called only if the signal has any handlers attached. The above code could be rewritten to:

Signal.lazy(mySignal, () => ({
  value: heavyFn()
}));

This utility function recognizes async signals and will return a Promise when appropriate.

Async Signals

Asynchronous signal interface is almost identical to its synchronous counterpart. The key difference is that an async signal will check the return type of every handler and handle all promises it receives.

When using async signals all promise rejections are guaranteed to be handled regardless of execution strategy used, errors may however get suppressed. See below for details.

The execution strategy of async handlers is configurable via the parallel option (see Creating a Signal).

Serial Execution

The default strategy is serial execution. Execution will await each handler before moving onto the next one.

This is the default strategy as it's the same one synchronous signals use. A promise rejection will immediately propagate upwards and terminate the execution. Handlers further down the list will not execute in such case.

const mySignal = Signal.createAsync();

Signal.on(mySignal, () => sleep(100));
Signal.on(mySignal, () => sleep(100));

// will take ~200ms
await mySignal();

Parallel Execution

When enabled, the signal will invoke all handlers simultaneously and resolve once all have resolved. If a handler rejects the wrapping promise returned by the signal will immediately reject as well. This is similar to the behavior of Promise.all.

const mySignal = Signal.createAsync({ parallel: true });

Signal.on(mySignal, () => sleep(100));
Signal.on(mySignal, () => sleep(100));

// will take ~100ms
await mySignal();

Note that if a handler rejects other handlers continue their execution and there is no way to await them. If additional rejections occur, they will be suppressed as the wrapping promise was already rejected with the first error.

It is a good practice to either make sure none of the handlers ever reject or to pass an abort signal through the event object so that you have some control over the still-pending actions if a rejection occurs, e.g.:

const abort = Signal.createSync();
try {
  await mySignal({ abort });
}
catch (ex) {
  console.error(ex);
  abort();
}

Note that the above example has nothing to do with browser AbortController or AbortSignal. However, you could use those for the same purpose too!

Forwarding this

Signals forward this to all its handlers, however there are several caveats to keep in mind when using this feature. These stem from how JavaScript functions and the binding of this work.

Any handler that needs to use the forwarded this has to be a regular function, not an arrow function. Signals also need to be called on the object that should be forwarded:

const obj = {
  value: 'foo',
  mySignal: Signal.create()
};

Signal.on(obj.mySignal, function () {
  console.log(this.value);
});

// will print 'foo'
obj.mySignal();

When signals are not called on an object, it is necessary to trigger them using the .call method to pass through this correctly:

const obj = { value: 'bar' };
const mySignal = Signal.create();

Signal.on(mySignal, function () {
  console.log(this.value);
});

// will print 'bar'
mySignal.call(obj);

Wrapping an EventEmitter

If you have an EventEmitter (Node) or an EventTarget (browser) that you wish to 'signalify' you can do so by passing a signal instance to the addEventListener method:

const confirmed = Signal.create<MouseEvent>();
const button = document.getElementById('ok-button');

button.addEventListener('click', confirmed);

Now every time the button is clicked the confirmed signal will trigger forwarding the MouseEvent object as well as this to all its handlers.

Signal Backend

Signals offer the choice between arrays and ES6 Maps as the backing data structure holding the list of registered handlers.

Array is the default structure as it is supported in every environment and offers the best performance for almost all use cases.

ES6 Map is only available in newer JS environments. Maps should only be preferred when dealing with a lot of on and off calls and infrequent invocations, as they provide a significant boost in such cases. Otherwise maps have a larger memory footprint, significantly decrease the performance of creating new signal instances and slightly reduce the performance of triggering them.

 create a signal instance
+  array   1,510,832,549 ops/sec ±0.07% (90 runs sampled)
-  es6map     29,258,272 ops/sec ±2.43% (92 runs sampled)
 
 trigger a signal with 1000 handlers
+  array         202,696 ops/sec ±0.06% (98 runs sampled)
-  es6map        154,100 ops/sec ±1.72% (89 runs sampled)
 
 add 1000 handlers, then clear
-  array           2,406 ops/sec ±0.42% (98 runs sampled)
+  es6map          9,055 ops/sec ±5.58% (58 runs sampled)
 
 attempt to remove an unknown handler from a signal with 1000 handlers
-  array       1,687,876 ops/sec ±0.17% (96 runs sampled)
+  es6map    159,076,218 ops/sec ±1.61% (89 runs sampled)

The above benchmark was generated with NodeJS v16.2.0 (V8 version: 9.0.257.25) on an AMD Ryzen 9 5950X CPU. You can run it on your machine using yarn benchmark.

Event Bubbling

Event bubbling is not implemented within the signal library, however it can be achieved with a helper function, e.g.:

import type { Signal, SignalArgs } from '@calmdownval/signal';

type BubbleTarget<T, TSignal extends string> =
  & { [K in TSignal]?: Signal<T> | null }
  & { parent?: BubbleTarget<T, TSignal> | null };

export async function bubble<T, TSignal extends string>(
  target: BubbleTarget<T, TSignal>,
  signal: TSignal,
  ...args: SignalArgs<T>
) {
  let current: BubbleTarget<T, TSignal> | null | undefined = target;
  do {
    await current[signal]?.(...args);
    current = current.parent;
  }
  while (current);
}

Calling bubble(obj, 'someEvent', event) will trigger the 'someEvent' signal with the event argument and then traverse upwards through parents triggering equivalent signals on each ancestor.

Changelog

A list of breaking changes for every major version:

  • 3.0.0
    • Handlers are now kept as unique refs, i.e. adding the same handler multiple times has no effect anymore.
    • Renamed type Handler to SignalHandler.
    • Renamed type HandlerOptions to SignalHandlerOptions.
  • 2.0.0
    • Signals now only pass the first argument to handlers.