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.
Write the three building blocks of reactive frameworks such as Solid, Preact Signals, and Angular: signal, computed, and effect.
signal(value) returns an object with a value getter and setter. Setting it to a value that's Object.is-equal to the current one does nothing.
computed(fn) returns an object with a read-only value getter. It's lazy: it calls fn only when its value is read, and caches the result until one of its dependencies changes.
effect(fn) runs fn right away, then again whenever one of its dependencies changes. It returns a dispose function. After dispose(), the effect never runs again.
Dependencies are tracked automatically: the signals and computeds that a computed or an effect reads while it runs are its dependencies. They're collected afresh on every run, so a signal that a branch no longer reads stops triggering it.
One write to a signal reruns each affected effect once, and an effect never sees a mix of old and new values.
const first = signal("Ada");
const last = signal("Lovelace");
const full = computed(() => first.value + " " + last.value);
const dispose = effect(() => console.log(full.value)); // logs "Ada Lovelace"
last.value = "Byron"; // logs "Ada Byron"
last.value = "Byron"; // same value: nothing
dispose();
first.value = "Augusta"; // nothing: the effect is disposed
const count = signal(1);
expect(count.value).toBe(1);
count.value = 5;
expect(count.value).toBe(5);
const count = signal(1);
const seen = [];
effect(() => seen.push(count.value));
expect(seen).toEqual([1]);
count.value = 2;
count.value = 3;
expect(seen).toEqual([1, 2, 3]);
const n = signal(NaN);
const word = signal("a");
const run = spy();
effect(() => {
n.value;
word.value;
run();
});
n.value = NaN;
word.value = "a";
expect(run).toHaveBeenCalledTimes(1);
const a = signal(2);
const calc = spy(() => a.value * 2);
const double = computed(calc);
expect(calc).not.toHaveBeenCalled();
expect(double.value).toBe(4);
expect(double.value).toBe(4);
expect(calc).toHaveBeenCalledTimes(1);
a.value = 5;
expect(calc).toHaveBeenCalledTimes(1);
expect(double.value).toBe(10);
expect(calc).toHaveBeenCalledTimes(2);
const first = signal("Ada");
const last = signal("Lovelace");
const full = computed(() => first.value + " " + last.value);
const mark = signal(".");
const seen = [];
effect(() => seen.push(full.value + mark.value));
last.value = "Byron";
mark.value = "!";
expect(seen).toEqual(["Ada Lovelace.", "Ada Byron.", "Ada Byron!"]);
const n = signal(1);
const double = computed(() => n.value * 2);
const quadruple = computed(() => double.value * 2);
const seen = [];
effect(() => seen.push(quadruple.value));
n.value = 3;
expect(seen).toEqual([4, 12]);
expect(quadruple.value).toBe(12);
const show = signal(true);
const name = signal("a");
const run = spy();
effect(() => {
run();
if (show.value) name.value;
});
show.value = false;
name.value = "b";
expect(run).toHaveBeenCalledTimes(2);
show.value = true;
name.value = "c";
expect(run).toHaveBeenCalledTimes(4);
const a = signal(1);
const b = computed(() => a.value * 2);
const c = computed(() => a.value * 3);
const seen = [];
effect(() => seen.push([a.value, b.value + c.value]));
a.value = 2;
expect(seen).toEqual([[1, 5], [2, 10]]);
const s = signal(0);
const seen = [];
const dispose = effect(() => seen.push(s.value));
s.value = 1;
dispose();
s.value = 2;
expect(seen).toEqual([0, 1]);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Signals with computed and effect. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.