Reactive Store with Proxy

Mental model: A Proxy is an interception layer, not a copy. Reading a key while an effect runs subscribes that effect to that key, and writing the key notifies exactly those subscribers.

Level: advanced · about 30 minutes

This is the lab where a framework stops being magic. Vue reactivity, Solid signals and MobX observables are the same three ideas you are about to write in about sixty lines: intercept reads to learn who depends on what, intercept writes to notify only those dependents, and collapse a burst of writes into one render. After this you will read framework source with a much steadier eye.

The lab gives you a live store and a wall of evidence. Buttons mutate it (count + 1, Add item, Rename user, delete state.note, Object.keys(state)), and three panels react: the UI rendered from the store, a dependency map showing which effect subscribed to which key, and a trap log in the order traps fired. Four counters run along the top: mutations, subscriber calls, renders, batches. The batching switch is the demo: three writes in one tick produce three mutations and exactly one render, until you turn it off.

The build, decision by decision

  1. Trap the read, forward it through Reflect Reflect.get performs the operation the trap intercepted, with the same receiver, so behaviour is unchanged and only the bookkeeping is new. Every trap in the lab follows this shape.
  2. Discover dependencies by running the code Push the effect on a stack, run it, and any key it touches registers itself. That is why you never write a dependency array: an if inside an effect genuinely changes what it is subscribed to on the next run.
  3. Keep one subscriber set per key A WeakMap from raw object to a Map of key to Set of effects. Per key, not per object, is what makes a store with fifty fields cheap: writing count never wakes the effect that only read name.
  4. Do not notify when nothing changed Assigning the same value is not a change. Object.is handles NaN and negative zero correctly, where === gets both wrong.
  5. Wrap nested objects lazily, and store raw values Wrapping on read means a deep tree costs nothing until someone looks at it. Unwrapping on write means proxies never nest inside your state, which would make identity checks impossible to reason about.
  6. Handle the array cases the engine hides from you Writing index 2 of a two-element array is an add, and the engine moves length itself without going through your trap. Ask before the write, then trigger length yourself. This is the line that makes push reactive.
  7. Give iteration its own dependency Object.keys, spread and for...in all take the ownKeys route, which is about the set of keys rather than any one key. Track it under a private symbol so adding a key can notify the effects that iterate.
  8. Batch: one Set, one microtask Ten mutations in one tick should cause one render. Add effects to a Set (which deduplicates for free) and drain it in a single queueMicrotask. Turn this off in the lab and the render counter jumps to match the mutation counter.
  9. Make teardown total with a revocable proxy Proxy.revocable gives you a kill switch. After revoke(), every operation on that proxy throws, which turns "something is still holding a reference to my store" from a silent leak into a loud error.
  10. Know exactly where it leaks Private fields are keyed to the instance, so a method reading #token throws when this is the proxy. Map.prototype.get runs with the proxy as this and never sees your traps. And reactive(obj) === obj is false, so an identity check against raw data quietly fails.

The core mechanism

const STACK = [];
const subscribers = new WeakMap();

function subsFor(raw, key) {
  let byKey = subscribers.get(raw);
  if (!byKey) subscribers.set(raw, (byKey = new Map()));
  let set = byKey.get(key);
  if (!set) byKey.set(key, (set = new Set()));
  return set;
}

function reactive(target) {
  return new Proxy(target, {
    get(t, key, receiver) {
      const value = Reflect.get(t, key, receiver);
      const running = STACK[STACK.length - 1];
      if (running && typeof value !== 'function') subsFor(t, key).add(running);
      return value;
    },
    set(t, key, value, receiver) {
      const before = t[key];
      const ok = Reflect.set(t, key, value, receiver);
      if (ok && !Object.is(before, value)) {
        for (const run of [...subsFor(t, key)]) run();
      }
      return ok;
    },
  });
}

function effect(fn) {
  const run = () => {
    STACK.push(run);
    try {
      fn();
    } finally {
      STACK.pop();
    }
  };
  run();
  return run;
}

const state = reactive({ count: 0, name: 'Ada', unread: 3 });

effect(() => console.log('render: count is ' + state.count));
// -> render: count is 0        (the first run is what discovers the dependency)

state.count = 1;      // -> render: count is 1
state.count = 1;      // same value, so no notification at all
state.unread = 99;    // nobody read unread, so nobody is told
state.count = 2;      // -> render: count is 2

Tracking and notification, complete, in about thirty lines.

Interactive visualiser: proxy. Enable JavaScript to use it.

const queue = new Set();
let scheduled = false;
let batches = 0;

function schedule(job) {
  queue.add(job);              // a Set, so scheduling the same job twice is free
  if (scheduled) return;
  scheduled = true;
  queueMicrotask(() => {
    scheduled = false;
    batches += 1;
    const jobs = [...queue];
    queue.clear();
    for (const j of jobs) j();
  });
}

let renders = 0;
const render = () => {
  renders += 1;
  console.log('render #' + renders);
};

// three mutations in the same tick
schedule(render);
schedule(render);
schedule(render);
console.log('mutations done, renders so far:', renders);   // -> mutations done, renders so far: 0

await new Promise((resolve) => queueMicrotask(resolve));
console.log('after the microtask:', renders, 'render(s) in', batches, 'batch');
// -> render #1
// -> after the microtask: 1 render(s) in 1 batch

Batching: three mutations, one render, one microtask.

const raw = { count: 0 };
const state = new Proxy(raw, {
  set(t, key, value, receiver) {
    return Reflect.set(t, key, value, receiver);
  },
});

state.count = 5;
console.log(state === raw, raw.count, state.count);

A proxy is a separate object that forwards operations, so state === raw is false while both report the same data. That mismatch is the leak to remember: pass a proxy where something later compares against the raw object and the check silently fails. This is why the lab keeps a WeakMap from proxy back to raw, and unwraps before storing.

TrapFires forTracks or triggers
getstate.count, state[key], method lookupstracks the key
setstate.count = 1, arr[3] = xtriggers the key, plus length and iterate for a new key
deletePropertydelete state.notetriggers the key and iterate
has'count' in statetracks the key
ownKeysObject.keys, spread, for...intracks the iterate symbol
apply / constructcalling or new-ing a proxied functionnot used here, but the same shape

Declared dependencies

useEffect(() => {
  draw(count, theme);
}, [count]);        // theme forgotten

// the list is a promise you
// make to the framework, and
// you can break it silently

Discovered dependencies

effect(() => {
  draw(state.count, state.theme);
});

// both keys were read, so both
// are subscribed. Change the
// body and the subscription
// changes with it

Discovery is the reason a signals-based framework has no dependency arrays. The cost is that a read has to happen while your effect is on the stack, which is why an await inside an effect can quietly lose the tracking for everything after it.

Extend it

  1. Add a computed(fn) that caches its value and only recomputes when one of its own dependencies changed.
  2. Add watch(getter, callback) that fires only when the value the getter returns actually differs.
  3. Add a readonly(target) wrapper whose set trap warns and returns true, for props you hand to children.
  4. Support Map and Set by wrapping their methods, and write down which method needs which trigger.
  5. Add a scheduler flag that flushes on the next animation frame instead of the next microtask, and measure which feels better under a fast burst of writes.

Try it yourself

Count the traps one line causes

const log = [];
const state = new Proxy(
  { a: 1, b: 2 },
  {
    get(t, k, r) {
      log.push('get ' + String(k));
      return Reflect.get(t, k, r);
    },
    has(t, k) {
      log.push('has ' + String(k));
      return Reflect.has(t, k);
    },
    ownKeys(t) {
      log.push('ownKeys');
      return Reflect.ownKeys(t);
    },
  }
);

const sum = state.a + state.b;
const copy = { ...state };
const asked = 'a' in state;
const keys = Object.keys(state);

console.log('sum', sum, 'keys', keys.join(','), 'asked', asked);
console.log(log.join(' | '));

Add a spread of the object, then a for...in. Which trap does each of them use, and how many times?

Dependencies change between runs

const reads = [];
const raw = { showDetail: false, summary: 'short', detail: 'long' };
const state = new Proxy(raw, {
  get(t, k, r) {
    reads.push(String(k));
    return Reflect.get(t, k, r);
  },
});

function render() {
  reads.length = 0;
  const out = state.showDetail ? state.detail : state.summary;
  console.log('rendered "' + out + '" after reading: ' + reads.join(', '));
}

render();                    // reads showDetail, summary
raw.showDetail = true;       // mutating raw on purpose: no trap, no notification
render();                    // reads showDetail, detail

Flip showDetail and watch which keys the effect is subscribed to. This is why a dependency array is a promise you can break.

Exercises

Write the Proxy traps

Write observable(target, notify) returning a Proxy. Reads forward through Reflect.get. Writes forward through Reflect.set and then call notify(key, value), but only when the key is new or the value actually changed (use Object.is, so NaN to NaN is not a change). delete forwards through Reflect.deleteProperty and calls notify(key, undefined) only when the key existed. The proxy is not the target, and writes must land on the target.

Batch effects into one microtask

Write createBatcher() with schedule(job), a live pending count and a live batches count. Scheduling adds the job to a set and, if no flush is already queued, queues exactly one queueMicrotask. The flush empties the set first, counts one batch, then runs each job once. A job scheduled while a flush is running belongs to the next batch, not this one.

Check yourself

What does this log?
notified then 42 — The first write goes straight to the raw object and no trap sees it, which is the single most common reactivity bug: something kept a reference to the original. The second write goes through the proxy, notifies, and lands on the target, so raw.count is 42.
Why does an effect that reads state.detail only inside an if sometimes not re-run when detail changes?
Because dependencies are collected while the effect runs, and on a run where the branch was not taken the key was never read — Tracking is a side effect of reading. No read, no subscription. That is a feature: the effect is subscribed to exactly what it used last time, and re-collecting on every run keeps it honest. It is also why the lab clears an effect dependencies before each run.
Batching is on. You write state.count five times in one function. What do the counters show?
5 mutations, 1 render, 1 batch — Each write is a real mutation and each one schedules the same effect, but the Set collapses them and only the first write queued a microtask. The render happens once, after your function returns and before the browser gets a chance to paint.
A class method reads a private field #token. You wrap the instance in your reactive proxy and call the method. What happens?
It throws a TypeError, because private fields are keyed to the instance and this is now the proxy — Private fields are not properties and never reach a trap. They are looked up on the exact object that was constructed, so calling the method with the proxy as this throws. The practical rule: make reactive state plain data, and keep class instances out of the store or unwrap before you call into them.

Common mistakes

  • Keeping a reference to the raw object and mutating it, so no trap fires and nothing updates.
  • Comparing values with === in the set trap, which reports a change for NaN and misses one for negative zero.
  • Forgetting that a new array index moves length without going through your trap.
  • Storing a proxy inside the state, so proxies nest and identity checks become unanswerable.
  • Tracking method lookups, which fills the dependency map with noise like constructor and filter.
  • Awaiting inside an effect and assuming reads after the await are still tracked. They are not.
  • Clearing the batch queue after running the jobs, which silently drops anything a job scheduled.

Takeaways

  • A Proxy is a door, not a watcher: only operations that go through it are observed.
  • Every trap does the real work with Reflect, then adds bookkeeping.
  • Dependencies are discovered by running the effect, which is why there is no dependency array.
  • One subscriber set per key keeps a large store cheap to write to.
  • A Set plus one microtask turns a burst of mutations into a single render.
  • The abstraction leaks at private fields, identity, collections and symbols. Know where before you rely on it.