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