HiveBrain v1.2.0
Get Started
← Back to all entries
snippetjavascriptTip

Implementing signals and reactivity with vanilla JavaScript

Submitted by: @import:30-seconds-of-code··
0
Viewed 0 times
javascriptimplementingandwithreactivitysignalsvanilla

Problem

In a past article, I took a look at how to use the Proxy object to implement the Observer pattern in JavaScript. This time, I want to explore a different approach to the popular signals pattern (hint: it's another name for the same pattern), using just event-driven programming.
> [!NOTE]
>
> This approach is very similar to the one in the previous article, using the EventTarget interface, which is common between the browser and Node.js environments. It just does away with the Proxy part and focuses more on events, using familiar naming conventions, such as signal and effect.
In most reactive-programming libraries nowadays, there's a concept of signals. A signal is a fancy name for an observable, a stream of values that can be listened to. When a signal changes, it notifies all its listeners, which can then react to the change.

Solution

class Signal extends EventTarget {
  #value;

  constructor(value) {
    super();
    this.#value = value;
  }

  get value() {
    return this.#value;
  }

  set value(newValue) {
    const nextValue =
      typeof newValue === 'function' ? newValue(this.#value) : newValue;
    if (nextValue === this.#value) return;
    this.#value = nextValue;
    this.dispatchEvent(new CustomEvent('notify', { detail: nextValue }));
  }
}

const signal = new Signal(42);
signal.addEventListener('notify', event => {
  console.log(`Signal changed to ${event.detail}`);
});

signal.value = 42;
// No change, no event
signal.value = 43;
// LOGS: Signal changed to 43
signal.value = value => value + 1;
// LOGS: Signal changed to 44


>
> This approach is very similar to the one in the previous article, using the EventTarget interface, which is common between the browser and Node.js environments. It just does away with the Proxy part and focuses more on events, using familiar naming conventions, such as signal and effect.
In most reactive-programming libraries nowadays, there's a concept of signals. A signal is a fancy name for an observable, a stream of values that can be listened to. When a signal changes, it notifies all its listeners, which can then react to the change.
We can implement a very simple Signal class, by extending EventTarget. We'll use a private class property to store the current value of the signal, which can then be accessed via get and set methods. The get method will return the value, while the set method will update the value and dispatch a CustomEvent to notify all listeners.
We'll also make sure that the set method only updates the value and notifies the listeners if the new value is different from the old one. Finally, we'll allow the set method to accept a function, which will be called with the current value and should return the new value.
Most reactive libraries, also provide a way to create effects, a fancier name for observers. An effect is a function that is called whenever any signal it depends on changes. This is a very powerful concept, as it allows us to react to changes in the system in a very declarative way.

Code Snippets

class Signal extends EventTarget {
  #value;

  constructor(value) {
    super();
    this.#value = value;
  }

  get value() {
    return this.#value;
  }

  set value(newValue) {
    const nextValue =
      typeof newValue === 'function' ? newValue(this.#value) : newValue;
    if (nextValue === this.#value) return;
    this.#value = nextValue;
    this.dispatchEvent(new CustomEvent('notify', { detail: nextValue }));
  }
}

const signal = new Signal(42);
signal.addEventListener('notify', event => {
  console.log(`Signal changed to ${event.detail}`);
});

signal.value = 42;
// No change, no event
signal.value = 43;
// LOGS: Signal changed to 43
signal.value = value => value + 1;
// LOGS: Signal changed to 44
class Effect {
  #subscriptions = new Set();

  constructor(callback, dependencies = []) {
    dependencies.forEach(dependency => {
      dependency.addEventListener('notify', callback);
      this.#subscriptions.add(() => {
        dependency.removeEventListener('notify', callback);
      });
    });
    callback();
  }

  dispose() {
    this.#subscriptions.forEach(unsubscribe => unsubscribe());
  }
}

const signal = new Signal(42);
const effect = new Effect(() => {
  console.log(`Effect triggered with value ${signal.value}`);
}, [signal]);
// LOGS: Effect triggered with value 42

signal.value = 43;
// LOGS: Effect triggered with value 43
effect.dispose();
signal.value = 44;
// No effect
class ComputedValue extends EventTarget {
  #signal;
  #effect;

  constructor(callback, dependencies = []) {
    super();
    this.#signal = new Signal(callback());
    this.#effect = new Effect(() => {
      this.#signal.value = callback();
    }, dependencies);
  }

  get value() {
    return this.#signal.value;
  }

  addEventListener(type, listener) {
    this.#signal.addEventListener(type, listener);
  }

  removeEventListener(type, listener) {
    this.#signal.removeEventListener(type, listener);
  }

  dispose() {
    this.#effect.dispose();
  }
}

const signal = new Signal(42);
const signalEffect = new Effect(() => {
  console.log(`Signal changed to ${signal.value}`);
}, [signal]);
// LOGS: Signal changed to 42
const computed = new ComputedValue(() => signal.value * 2, [signal]);
const computedEffect = new Effect(() => {
  console.log(`Computed value changed to ${computed.value}`);
}, [computed]);
// LOGS: Computed value changed to 84

signal.value = 43;
// LOGS: Signal changed to 43
// LOGS: Computed value changed to 86

Context

From 30-seconds-of-code: event-driven-vanilla-js-signals

Revisions (0)

No revisions yet.