Skip to the code
cf.

Reactivity with Proxy

Vue tracks changes to plain objects with a Proxy. Write the two functions at the core of that: reactive and watchEffect.

reactive(object) returns a proxy for an object or an array. Reading and writing the proxy's properties reads and writes the original object. Calling reactive again with the same object, or with a proxy it returned, returns the same proxy.

Reading a property whose value is an object or an array, through a proxy, returns reactive(value). So nested data is reactive too, and reading it twice gives the same proxy.

watchEffect(fn) calls fn right away and tracks every property that fn reads through a proxy. When one of those properties is set to a value that isn't Object.is-equal to the old one, or is deleted, fn runs again, synchronously, during the write. watchEffect returns a stop function, and after stop(), fn never runs again.

Tracking is per object and per property: a write reruns only the effects that read that property of that object. The tracked properties are collected afresh on every run, so a property that a branch no longer reads stops rerunning the effect.

Reading a property that doesn't exist yet, or checking it with in, tracks it too, so adding it reruns the effect. Listing the keys, with Object.keys or for...in, tracks the list of keys: adding or deleting any property reruns the effect.

An array's length is a property like the others. When a write changes it, effects that read length rerun, including when push, pop, or a write past the end changes it.

An effect never reruns because of its own writes while it's running, so an effect may write to properties it reads.

const state = reactive({ user: { name: "Ada" }, todos: ["Write tests"] });
 
watchEffect(() => console.log(state.user.name + ": " + state.todos.length));
// logs "Ada: 1"
state.user.name = "Grace";   // logs "Grace: 1"
state.todos.push("Ship it"); // logs "Grace: 2"
state.user.name = "Grace";   // the same value: logs nothing
state.theme = "dark";        // the effect never read theme: logs nothing

What the tests check

  1. a proxy reads and writes the original object, and each object gets one proxy
    const raw = { count: 1, user: { name: "Ada" } };
    const state = reactive(raw);
    expect(state.count).toBe(1);
    state.count = 2;
    expect(raw.count).toBe(2);
    expect(state.user.name).toBe("Ada");
    expect(state.user).toBe(state.user);
    expect(reactive(raw)).toBe(state);
    expect(reactive(state)).toBe(state);
    
  2. an effect runs right away, again on every change, and never after stop()
    const state = reactive({ count: 1 });
    const seen = [];
    const stop = watchEffect(() => seen.push(state.count));
    expect(seen).toEqual([1]);
    state.count = 2;
    state.count++;
    expect(seen).toEqual([1, 2, 3]);
    stop();
    state.count = 4;
    expect(seen).toEqual([1, 2, 3]);
    
  3. only the properties it read, on the objects it read them from, rerun it
    const first = reactive({ name: "Ada", age: 36 });
    const second = reactive({ name: "Grace" });
    const run = spy();
    watchEffect(() => {
      run();
      first.name;
    });
    first.age = 37;
    second.name = "Hopper";
    expect(run).toHaveBeenCalledTimes(1);
    first.name = "Lovelace";
    expect(run).toHaveBeenCalledTimes(2);
    
  4. writing an Object.is-equal value does nothing
    const state = reactive({ n: NaN, word: "a", zero: 0 });
    const run = spy();
    watchEffect(() => {
      run();
      state.n;
      state.word;
      state.zero;
    });
    state.n = NaN;
    state.word = "a";
    expect(run).toHaveBeenCalledTimes(1);
    state.zero = -0;
    expect(run).toHaveBeenCalledTimes(2);
    
  5. nested objects and arrays are reactive
    const state = reactive({ user: { name: "Ada" }, tags: ["a"] });
    const seen = [];
    watchEffect(() => seen.push(state.user.name + ":" + state.tags.join(",")));
    state.user.name = "Grace";
    state.tags[0] = "b";
    expect(seen).toEqual(["Ada:a", "Grace:a", "Grace:b"]);
    
  6. dependencies are collected again on every run
    const state = reactive({ show: true, name: "a" });
    const run = spy();
    watchEffect(() => {
      run();
      if (state.show) state.name;
    });
    state.show = false;
    state.name = "b";
    expect(run).toHaveBeenCalledTimes(2);
    state.show = true;
    state.name = "c";
    expect(run).toHaveBeenCalledTimes(4);
    
  7. adding or deleting a property reruns effects that read it, check it with in, or list the keys
    const state = reactive({ a: 1 });
    const values = [];
    const has = [];
    const keys = [];
    watchEffect(() => values.push(state.b));
    watchEffect(() => has.push("b" in state));
    watchEffect(() => keys.push(Object.keys(state).join(",")));
    state.b = 2;
    expect(values).toEqual([undefined, 2]);
    expect(has).toEqual([false, true]);
    expect(keys).toEqual(["a", "a,b"]);
    delete state.b;
    expect(values).toEqual([undefined, 2, undefined]);
    expect(has).toEqual([false, true, false]);
    expect(keys).toEqual(["a", "a,b", "a"]);
    state.a = 5;
    expect(keys).toEqual(["a", "a,b", "a"]);
    
  8. push, pop, and writes past the end rerun effects that read length
    const list = reactive([1, 2]);
    const seen = [];
    watchEffect(() => seen.push(list.length));
    list.push(3);
    expect(seen).toEqual([2, 3]);
    list.pop();
    expect(seen).toEqual([2, 3, 2]);
    list[5] = 6;
    expect(seen).toEqual([2, 3, 2, 6]);
    list[0] = 10;
    expect(seen).toEqual([2, 3, 2, 6]);
    
  9. an effect doesn't rerun because of its own writes
    const state = reactive({ count: 0, runs: 0 });
    const log = reactive([]);
    watchEffect(() => {
      state.runs++;
      log.push(state.count);
    });
    expect(state.runs).toBe(1);
    state.count = 5;
    expect(state.runs).toBe(2);
    expect(log.join(",")).toBe("0,5");
    

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