Skip to the code
cf.

Signals with computed and effect

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

What the tests check

  1. a signal holds a value you can read and set
    const count = signal(1);
    expect(count.value).toBe(1);
    count.value = 5;
    expect(count.value).toBe(5);
    
  2. an effect runs right away, then again when a signal it read changes
    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]);
    
  3. setting an Object.is-equal value does nothing
    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);
    
  4. a computed runs only when read, and caches until a dependency changes
    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);
    
  5. an effect tracks a computed it reads, and the signals it reads after it
    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!"]);
    
  6. computeds can read other computeds
    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);
    
  7. dependencies are collected again on every run
    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);
    
  8. one write reruns an effect once, with no mix of old and new values
    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]]);
    
  9. dispose stops an effect for good
    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]);
    

Learn more

solution.jsSaved in this browser

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).