Reactivity

How do you know when state changes?

The problem

You have a variable: let count = 0. Someone writes count = 5. How does your UI know to update?

In the old days (jQuery), you'd manually call $('#counter').text(count) every time you changed it. Tedious. Error-prone. The whole point of a framework is to automate this.

The question is: how do you detect that count changed?

The history

Four approaches have been tried. Only one survived.

~2010 — Angular 1

Dirty Checking

Run a "digest cycle" on every user action. Compare every watched value to its previous value. If different, update the DOM. Repeat until nothing changes.

// Pseudo-code for dirty checking
function digest() {
  let dirty = true;
  while (dirty) {
    dirty = false;
    for (const watcher of watchers) {
      const newVal = watcher.get();
      if (newVal !== watcher.last) {
        watcher.callback(newVal);
        watcher.last = newVal;
        dirty = true;  // something changed, go again
      }
    }
  }
}

The problem: O(n) on every digest. With 2000 bindings, you're comparing 2000 values. Multiple times. On every click.

~2014 — Dead API

Object.observe

Native browser API to watch object changes. Would have been perfect. Chrome implemented it. Then everyone abandoned it in favor of Proxies.

// RIP Object.observe (2014-2015)
Object.observe(obj, function(changes) {
  changes.forEach(function(change) {
    console.log(change.name, change.oldValue, change.object[change.name]);
  });
});

Status: Removed from browsers. Don't use.

~2014 — Vue 2

Object.defineProperty

Redefine each property with a getter/setter. The setter fires your callback.

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      return val;
    },
    set(newVal) {
      if (newVal !== val) {
        val = newVal;
        notify();  // ← trigger update
      }
    }
  });
}

The problem: Can't detect new properties. obj.newProp = 'x' doesn't trigger anything because the getter/setter was never defined for newProp. Vue 2 needed Vue.set() as a workaround.

~2016 — Modern

Proxy

Wrap the entire object in a trap. Intercept all property access—get, set, delete, even in checks.

const state = new Proxy(data, {
  set(target, prop, value) {
    target[prop] = value;
    onUpdate(prop);  // ← THE MAGIC
    return true;
  },
  get(target, prop) {
    return target[prop];
  }
});

The win: Works with new properties. Works with deletions. Works with arrays. No special methods needed.

qrazy's implementation

Here's the actual code from qrazy.js:

function reactive(data, onUpdate) {
  return new Proxy(data, {
    set(target, prop, value) {
      target[prop] = value;
      onUpdate(prop);
      return true;
    },
    get(target, prop) {
      return target[prop];
    }
  });
}

That's it. 11 lines. When you write state.count = 5, the Proxy's set trap fires, which calls onUpdate('count'), which triggers all the DOM bindings that care about count.

The array problem

Arrays are tricky. arr[0] = 'x' triggers the Proxy's set trap. But arr.push('x') doesn't—it calls a method, not a setter.

qrazy's solution: intercept the method calls.

function deepReactive(data, onUpdate) {
  if (Array.isArray(data)) {
    return new Proxy(data, {
      get(target, prop) {
        // Intercept mutating methods
        if (['push', 'pop', 'splice', ...].includes(prop)) {
          return (...args) => {
            const result = Array.prototype[prop].apply(target, args);
            onUpdate('length');  // ← notify on mutation
            return result;
          };
        }
        return target[prop];
      }
    });
  }
  return reactive(data, onUpdate);
}

When you call todos.push({...}), you're actually calling our wrapper function, which calls the real push, then triggers an update.

Why is this hard? Arrays have 7 mutating methods: push, pop, shift, unshift, splice, sort, reverse. Each one needs interception. Vue 2 famously couldn't handle arr[index] = value because of defineProperty limitations.

Signals vs Proxies

There's another approach gaining popularity: signals (used by Solid, Preact Signals, Angular 16+).

// Proxy-based (Vue, qrazy)
const state = reactive({ count: 0 });
state.count++;  // triggers update

// Signal-based (Solid)
const [count, setCount] = createSignal(0);
setCount(count() + 1);  // triggers update

The difference:

  • Proxies are coarse-grained. Any property change triggers a check of all bindings.
  • Signals are fine-grained. Each signal knows exactly which DOM nodes depend on it.

Signals are theoretically faster (no unnecessary checks), but Proxies feel more natural (just use normal objects). qrazy uses Proxies because they're simpler to understand—and for learning, simplicity wins.

Interactive demo

See the Proxy trap fire in real time:

Try it: Proxy trap logger

Click the button to see Proxy traps fire...

This demo runs on qrazy itself. View source to see the q-data, q-click, and q-for directives.

First principles

What is state? It's a value that changes over time and affects what the user sees. The fundamental question of reactivity is: how do you observe change?

Dirty checking says: poll and compare. Slow, but simple.
defineProperty says: intercept at the property level. Better, but incomplete.
Proxy says: intercept at the object level. Complete, and the right abstraction.

The Proxy API is JavaScript finally giving us the primitive we needed. Everything before it was a workaround.