Web Performance Calendar

The speed geek's favorite time of year
2022 Edition
ABOUT THE AUTHOR
Andrea Giammarchi photo

Andrea Giammarchi (@WebReflection) is a Web standards advocate developer and veteran with great focus on performance, cross-platform compatibility (polyfills author) and passion all over the field when it comes to pushing the Web forward

Mostly revealed to the Web by Solid.js, shortly after embraced by Preact, but also blended into usignal, or implemented by many others, this pattern convinced tons of developers that hooks might not be the best solution to reactivity out there, but while the concept might look and feel super simple and natural to deal with, the complexity behind spans from 30LOC to pretty convoluted code bases.

This post would like to explain the logic, performance and memory challenges, behind this pattern, which can be represented in its most primitive form by the following code, which is mimicking the Preact API (not its code):

const effects = [Function.prototype];
class Signal {
  #value;
  #effects = new Set;
  constructor(value) {
    this.#value = value;
  }
  get value() {
    this.#effects.add(effects.at(-1));
    return this.#value;
  }
  set value(value) {
    if (this.#value !== value) {
      this.#value = value;
      for (const effect of this.#effects)
        effect();
    }
  }
  peek() { return this.#value }
  toString() { return String(this.value) }
  valueOf() { return this.value }
}
const signal = value => new Signal(value);
const effect = fn => {
  effects.push(fn);
  try { fn() }
  finally { effects.pop() }
};

… don’t worry though, I am going to describe all operations there, and in a TS friendly way, but feel free to test already some playground code in console, after copy pasting that bit, plus the following one:

const single = signal(1);
const double = signal(10);
const triple = signal(100);

effect(() => {
  console.log(`
  #1 effect
    single: ${single}
    double: ${double}
  `);
});
// logs single 1, double 10

effect(() => {
  console.log(`
  #2 effect
    double: ${double}
    triple: ${triple}
  `);
});
// logs double 10, triple 100

++single.value;
// logs single 2, double 10

++double.value;
// logs single 2, double 11
// logs double 11, triple 100

++triple.value;
// logs double 11, triple 101

Congratulations: you’ve just successfully tested (quite possibly) the terser implementation ever of signals partying_face (including its memory leaks issues hankey)!

Signals in a nutshell

A signal is “a wrapped value” that automatically subscribes any callback accessing it while it’s executing. As a stretched metaphore, it’s like an object that automatically add listeners reaching itself and dispatches those listeners whenever the wrapped value changes.

In order to do so, it uses, at least in this post examples, an accessor that automatically adds the currently executing function to its list of “listeners”, here rather called as effects, where thanks to the synchronous nature of callbacks executions can be handled by a simple push/pop stack.

In fewer words, any effect that reaches any signal while executing, will be invoked again whenever any of the reached signals will change their values in the future.

Different Implementations

What’s maybe curious to know is how libraries differ in terms of implementations:

  • solid.js approach is similar to React hooks: const [value, update] = createSignal(1). Signals are accessed via invokes, such as value(), and updated also via invokes: update(value + 1). Beside this choice, the orchestration happens through tooling, as opposite of runtime, as it is for Preact and usignal or others.
  • Preact uses linked list / chained references to orchestrate the whole logic, allowing it to likely never fall for maximum callstack limit and granting linearly good performance no matter the amount of the effects or signals used in the program.
  • usignal strives for code size and simplicity, and it uses an always re-assigned reference instead of a basic push/pop stack, granting very competitive performance but inevitably falling into the maximum callstack limit vulnerable category (although the limit is very high, hence not a real-world issue, still a known limitation of its approach)
  • this post uses a simple push/pop logic but even arrays have a limit of entries they can handle so it’s still theoretically vulnerable to “too many effects” issues.

Challenge No.1 – Memory leaks

The first code example will be explained in a bit but it should be already obvious that once an effect has been created, and until any of its reached signals will be referenced in our program, there is no way to stop listening to changes, hence there is no way to unsubscribe to a signal.

Of course for tiny software that does few things once and never need to drop effects during execution this is not an issue, but we can imagine how this situation would poorly scale in anything slightly more complex.

This is why most signals based libraries offer a method to stop reacting to specific signals, and let the Garbage Collector get rid of those callbacks. This helper is usually called dispose().

Before going deeper into this topic though, let’s try to understand the initial code, also adding a way to dispose effects:

// a stack of running effects with a root no-op function
// to simplify the rest of the logic without needing to
// worry about no-op executions
/** @type {function[]} */
const effects = [Function.prototype];

// a weakly related list of effects that cannot be freed
// until signals that registered these effects are updated
const disposed = new WeakSet;

// the basic class that wraps values and provides helpers
class Signal {
  // PRIVATE FIELDS
  /** @type {T} */    #value;
  /** @type {Set} */  #effects = new Set;

  /** @param {T} value the value carried through the signal */
  constructor(value) {
    this.#value = value;
  }

  // SIGNAL R/W SIDE EFFECT LOGIC
  /** @returns {T} */
  get value() {
    // add (once) whatever effect is currently running
    // no effects means add (once) the root no-op
    this.#effects.add(effects.at(-1));
    // return the wrapped value
    return this.#value;
  }

  /** @param {T} value the new value carried through the signal */
  set value(value) {
    // trigger effects only when a new value to wrap is passed along
    // which is usually the most desirable use-case to deal with
    if (this.#value !== value) {
      this.#value = value;
      // for all subscribed effects, including the no-op
      for (const effect of this.#effects) {
        // verify that the effect was not flagged for disposal
        if (disposed.has(effect))
          // if that's the case, delete it and never bother again with it
          this.#effects.delete(effect);
        // if not disposed, invoke the effect which will reach again this signal
        // it won't be added as already known once the value is reached
        // and it will simply use the latest value the signal provides
        else
          effect();
      }
    }
  }

  // EXPLICIT NO SIDE EFFECTS
  // used to *not* subscribe effects (avoid seppuku)
  peek() { return this.#value }

  // IMPLICIT SIDE EFFECTS
  // there could be more but these are handy for
  // automatic string conversion (DOM attributes, text, etc)
  // or automatic numeric operations: symbol(1) + symbol(2)
  toString() { return String(this.value) }
  valueOf() { return this.value }
}

/** @param {T} value the value carried through the new Signal */
const signal = value => new Signal(value);

/**
 * @param {function():void} fn the callback to invoke as effect
 * @returns {function():void} a callback to dispose the effect
 */
const effect = fn => {
  // add the callback to the stack
  effects.push(fn);
  try {
    // execute it and return a way to dispose it
    fn();
    return () => {
      disposed.add(fn);
    };
  }
  // no matter what, free the stack once the fn has been executed
  // propagating possible errors through the program but not messing up
  // the whole stack of callbacks
  finally {
    effects.pop();
  }
};

Using this updated and commented code, it is possible to now arbitrarily hold on dispose callbacks, and invoke these whenever we need to:

const single = signal(1);
const double = signal(10);
const triple = signal(100);

const dispose1 = effect(() => {
  console.log(`
  #1 effect
    single: ${single}
    double: ${double}
  `);
});

const dispose2 = effect(() => {
  console.log(`
  #2 effect
    double: ${double}
    triple: ${triple}
  `);
});

++double.value;
// logs single 1, double 11
// logs double 11, triple 100

dispose2();

++double.value;
// logs single 1, double 11

That is: with some tiny extra logic we are now able to unsubscribe effects whenever we like, as opposite of having these running forever until all signals are garbage collected.

We still have some issue though:

  • with this code, signals will free subscribed effects only when their values will change again, or whenever these become fully unreachable, and the same should happen to the returned dispose too.
  • adding a FinalizationRegistry and a WeakRef in the equation, would easily bloat the code and yet it won’t guarantee effects are freed right away + there is no way to know if the dispose helper is retained within the effect creation, so this road is more cumbersome than it looks like, as the relation should be within involved signals too and their lifecycle, bringing us back to the previous point.

These issues are one of the reason real-world signals implementations can look extremely convoluted: everyone is trying to “limit the damage”, but such damage is not strictly much worse than listeners added and never removed, something many programs might still have out there.

As summary, the rule of thumbs here is: be sure you dispose the previous effect, if any, when an effect is created at the same logical point of the program each time. Feel free, otherwise, to go wild with GC helpers I’ve mentioned in the previous points.

Challenge No.2 – Performance

There are few things the code used in this post is not addressing:

  • a batched update: if an effect reaches more than a signal but all of them are updated at once, the effect will run O(n) times until all signals have been reached up to their latest value.
  • nested effects: if a signal updates an effect that contains other effects, there must be some automatic disposal of previous effects, otherwise there will be a concurrent multiple effects re-addressed each time until the heap blows and the execution of the outer effect becomes extremely slow.
  • automatic cleanup: if an effect has been run due changes, it should be removed from the list of effects to trigger next, because conditional operations might happen inside an effect, so that one part that reached a signal won’t necessarily reach other parts in the future … here an example:
effect(() => {
  if (first.value % 2)
    console.log(second.value);
  else
    console.log(third.value);
});

In this example first.value could be any number and either second or third signals might be reached during a specific execution in time. That means that above effect could be subscribed to both first and second or both first and third, but never to all of them.

The gist of this example is that orchestrating all possible conditional use cases without bloating CPU usage or heap is pretty tricky, but in a way or another most libraries managed to get all these use cases done, which is why I suggest, unless your logic is free from conditional and nested effects, to consider one of these.

Challenge No.3 – Computed

Blending all problems described until now, a computed signal is a mix between an effect and a signal itself that contains operations that should execute only if any of the signals changed in the meanwhile, or only once accessed like a signal. Not all libraries here agreed around computed values, and this is a very basic example of a computed utility that reacts even if nobody accessed its .value:

const computed = fn => {
  // create a void wrapper
  const signal = new Signal;
  // create an effect that updates the signal
  // with the value returned by the callback
  const dispose = effect(() => {
    signal.value = fn();
  });
  // return the signal augmented with the `dispose` helper
  return Object.assign(signal, {dispose});
};

Keeping the previous example with numbers, this is how a computed could be used:

const single = signal(1);
const double = signal(10);
const triple = signal(100);

const sum = computed(() => single + double + triple);

sum.value;
// logs 111

++single.value;
sum.value;
// logs 112

sum.dispose();

++single.value;
sum.value;
// still logs 112, no more updates

The tricky part around computed is that these are even more prone to leaks being both signals and effects. Add the ability to either update on demand or lazily, the orchestration around computed can also be quite tricky + the fact they look like signals might help forgetting their disposal within our code.

Some Benchmark

Already linked at the top, there is this comparison with customizable amount of signals or effects to deal with.

usignal also has an unofficial benchmark, but other projects might offer something similar.

usignal benchmark

Conclusion

Signals are every day more popular but it’s not instantly clear, at first glance, what are the challenges behind, why code size only is not a good metric, and why code bases around these are likely convoluted.

I would lie telling you that if this pattern was provided natively as JS API it wouldn’t be glorious time for BE to FE development, but it’s great that there are various alternatives to chose from but still we should be careful avoiding footguns misusage of this pattern can lead to, the same we have with hooks or even listeners, if we keep adding these forever without cleaning previous one, as example.

Signals here are usually fast, extremely fast if we take this post code as example, but all the details behind matter and I hope this post will help using these wisely and efficiently in the next project.

P.S. a playground repository with a slightly more advanced implementation (w/ batches + inner effects disposal via outer effects) than the one showed in this post can be found here wink