Build tiny reactive signals: signal, computed, and effect with automatic dependency tracking.
Before you start
What are signals?
Signals are a way of holding state so that everything worked out from it stays up to date on its own. There are three pieces. A signal holds one value, which you can read and change. A computed holds a value worked out from other signals, and stays in step with them. An effect is code that does something with values, such as updating the page or logging: it runs once, then again whenever a value it read changes. Frameworks such as Solid use them to update only the parts of the page that read a changed value.
A spreadsheet works the same way. A1 and B1 hold numbers you type in: they're signals. C1 holds =A1+B1: it's a computed. A chart drawn from C1 is an effect. Change A1, and C1 and the chart update, though you never told the spreadsheet that the chart depends on A1.
Here are the three with Preact Signals, whose signal, computed, and effect come from the @preact/signals-core package. You read and set a signal through its .value:
const first = signal("Grace");
const last = signal("Hopper");
const full = computed(() => first.value + " " + last.value);
const dispose = effect(() => console.log(full.value)); // logs "Grace Hopper" right away
last.value = "Kelly"; // logs "Grace Kelly"
first.value = "Gene"; // logs "Gene Kelly"
dispose(); // stops the effect for good
first.value = "Ned"; // logs nothing
| Step | Why | Logged |
|---|---|---|
effect(...) | An effect runs once right away. It reads full, and working out full reads first and last. Those reads are recorded: the effect depends on full, and full on first and last. | Grace Hopper |
last.value = "Kelly" | full read last, so it's out of date. The effect read full, so it runs again. | Grace Kelly |
first.value = "Gene" | The same, through first. | Gene Kelly |
first.value = "Ned", after dispose() | A disposed effect no longer depends on anything. | Nothing |
That's automatic dependency tracking. While a computed or an effect runs, the library notes every signal and computed it reads; those are its dependencies, and only a change to one of them makes it run again. You never write that list yourself, as you do for React's useEffect(fn, [a, b]). It's collected again on every run, so if an if stops reading a signal, changes to that signal stop mattering. A computed is usually lazy, too: it works its value out when something reads it, and keeps the result until a dependency changes. Each framework has its own names for the three pieces:
| Library | Signal | Computed | Effect |
|---|---|---|---|
| Preact Signals | signal(v), read and set with .value | computed(fn) | effect(fn) |
| Solid | createSignal(v), which returns a getter and a setter | createMemo(fn) | createEffect(fn) |
| Angular | signal(v), read by calling it, set with .set(v) | computed(fn) | effect(fn) |
| Vue | ref(v), read and set with .value | computed(fn) | watchEffect(fn) |
A glitch is a moment when an effect or a computed sees a mix of old and new values, a combination the app was never in. Say an effect logs first.value + " / " + full.value while the name is Grace Kelly, and you set first to "Gene". If the effect could run after first had changed but before full caught up, it would log Gene / Grace Kelly, then run again and log Gene / Gene Kelly. A glitch-free library promises that each effect sees only consistent values, and runs once for each change.
signal(value) returns an object with a value property you can read and set.
computed(fn) returns an object with a read-only value. fn runs lazily, only when value is read, and its result is cached until a signal or computed that fn read changes.
effect(fn) runs fn now, and again whenever a signal or computed it read during its last run changes. It returns a dispose function that stops it.
Setting a signal to an Object.is-equal value does nothing. Dependencies are collected again on every run, so a branch that's no longer taken stops triggering. An effect must never see a mix of old and new values, and one signal write reruns each affected effect once.
function signal(value) {}
function computed(fn) {}
function effect(fn) {}
// const count = signal(1);
// const double = computed(() => count.value * 2); // fn doesn't run yet
// const dispose = effect(() => console.log(double.value)); // logs 2
// count.value = 5; // logs 10
// count.value = 5; // logs nothing: the value didn't change
// dispose();
// count.value = 6; // logs nothing: the effect is disposed
Solve it in the editor, with tests
The answer
Write it yourself first. Then compare it with a reference solution and the checks it passes.
Checking your sign-in…