Updated Jul 18, 2026

Reactive Objects

Every reactivity system - Vue's, Svelte's, the one you're about to write - rests on one capability: knowing when data is read and when it's written. Plain JavaScript objects don't report either. But the language ships a tool that wraps any object and intercepts everything done to it: the Proxy. Today you build with it.

Meet the Proxy

A Proxy wraps a target object and routes operations through traps - functions you supply. Two traps carry this whole project: get (a property was read) and set (a property was written). Run it:

const user = { name: 'Ada', plan: 'pro' };

const spied = new Proxy(user, {
  get(target, key) {
    console.log(`READ  ${String(key)}`);
    return target[key];
  },
  set(target, key, value) {
    console.log(`WRITE ${String(key)} = ${value}`);
    target[key] = value;
    return true; // set traps must return true on success
  },
});

// Use it like a normal object - the traps fire invisibly:
const n = spied.name;
spied.plan = 'enterprise';
console.log('Reads and writes went through, value is:', spied.plan);

What just happened: spied behaves exactly like user - same properties, same values - but every access ran through your traps first. The object can now announce its own reads and writes. That announcement is the entire foundation: a framework that hears "someone read name" and later "someone wrote name" knows exactly which screen updates matter.

From spy to subscription ledger

Logging is a demo; a framework needs bookkeeping. The plan: when a property is read, remember who was asking (we'll wire that up properly in phase 2 - for now, a placeholder). When it's written, look up everyone who asked and notify them. The ledger:

// A two-level map: object -> (key -> Set of subscribers)
const ledger = new WeakMap();

function subscribe(target, key, fn) {
  let keyMap = ledger.get(target);
  if (!keyMap) ledger.set(target, (keyMap = new Map()));
  let subs = keyMap.get(key);
  if (!subs) keyMap.set(key, (subs = new Set()));
  subs.add(fn);
}

function notify(target, key) {
  const subs = ledger.get(target)?.get(key);
  if (subs) for (const fn of subs) fn();
}

// Try the ledger on its own:
const state = { count: 0 };
subscribe(state, 'count', () => console.log('count changed!'));
subscribe(state, 'count', () => console.log('me too!'));
notify(state, 'count');

What just happened: subscribe files a function under (object, property); notify runs everything filed there. A WeakMap keyed by the object means the bookkeeping disappears when the object does - no leak. A Set per property means the same subscriber can't be filed twice.

reactive(): the two pieces joined

Now the move that makes it automatic - the proxy's traps call the ledger:

const ledger = new WeakMap();

function subscribe(target, key, fn) {
  let keyMap = ledger.get(target);
  if (!keyMap) ledger.set(target, (keyMap = new Map()));
  let subs = keyMap.get(key);
  if (!subs) keyMap.set(key, (subs = new Set()));
  subs.add(fn);
}

function notify(target, key) {
  const subs = ledger.get(target)?.get(key);
  if (subs) for (const fn of subs) fn();
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // Phase 2 will subscribe the *current effect* here automatically.
      return target[key];
    },
    set(target, key, value) {
      if (target[key] === value) return true;  // no change, no noise
      target[key] = value;
      notify(target, key);                     // announce to subscribers
      return true;
    },
  });
}

// Demo: manual subscription, automatic notification.
// Note we subscribe on the RAW object: the traps file their bookkeeping under it
// (the `target` they receive), so lookups must use the same key. Phase 2's
// automatic tracking happens inside the traps, which makes this seam invisible.
const rawCart = { items: 0, total: 0 };
const cart = reactive(rawCart);

subscribe(rawCart, 'items', () => console.log(`UI update: badge shows ${cart.items}`));
subscribe(rawCart, 'total', () => console.log(`UI update: total shows ${cart.total}`));

cart.items = 1;      // only the badge subscriber fires
cart.total = 1900;   // only the total subscriber fires
cart.items = 1;      // same value: nothing fires (check the guard in set)
cart.items = 2;

What just happened: writes now notify precisely the subscribers of that property - update items and the total subscriber stays quiet. The target[key] === value guard skips no-op writes, which real frameworks also do (you met it as "signals compare by reference" if you've read the Angular guide). Two loose ends remain, both deliberate: the subscribing is still manual, and you had to know about the raw-vs-proxy seam to file subscriptions under the right key. Phase 2 fixes both at once - automatic tracking lives inside the traps, where target is always the raw object - and it's the best trick in frontend engineering.

Your turn

Extend reactive's set trap so it also logs a warning - without notifying - when code tries to write a property that didn't exist on the original object (a typo catcher: cart.tota = 5 should warn, not silently create a property). Object.hasOwn(target, key) tells you if the key existed. Then prove it works:

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      return target[key];
    },
    set(target, key, value) {
      // YOUR CODE: if the key is NOT already on target, console.log a warning
      // like `unknown property "tota" - typo?` and return true WITHOUT writing.
      // Otherwise write and return true (skip notify - no ledger in this block).
      target[key] = value;
      return true;
    },
  });
}

const cart = reactive({ items: 0, total: 0 });
cart.items = 3;      // should write silently
cart.tota = 99;      // should WARN and not create the property
console.log('items:', cart.items, '| tota exists:', 'tota' in cart); // 3, false

Recap

  1. A Proxy wraps an object and intercepts reads (get) and writes (set) - data that can announce itself.
  2. The ledger is a WeakMap(object → Map(key → Set(subscriber))) - per-property precision, leak-free by construction.
  3. reactive() = proxy + ledger: writes notify exactly the right subscribers, no-op writes are filtered.
  4. Reads are the missing half - automating "who was asking" is phase 2.

← Guide overview · Phase 2: Effects and Computed →