snippetjavascriptTip
Implementing signals and reactivity with vanilla JavaScript
Viewed 0 times
javascriptimplementingandwithreactivitysignalsvanilla
Problem
In a past article, I took a look at how to use the
> [!NOTE]
>
> This approach is very similar to the one in the previous article, using the
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.
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 44class 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 effectclass 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 86Context
From 30-seconds-of-code: event-driven-vanilla-js-signals
Revisions (0)
No revisions yet.