A Proxy wraps an object and intercepts operations on it — reading (get), writing (set), deleting and more — through handler functions called traps. Reflect provides the default behaviour for each trap so you can add logic and then carry on normally.
Proxies power validation layers, logging, default values and, most famously, reactivity: frameworks such as Vue detect which data changed and re-render only what depends on it.
Below, a 30-line reactive store notifies subscribers whenever a property changes, batching several changes into one update with a microtask.
function validated(target, rules) {
return new Proxy(target, {
set(obj, key, value, receiver) {
const rule = rules[key];
if (rule && !rule(value)) throw new TypeError(`Invalid value for ${String(key)}: ${value}`);
return Reflect.set(obj, key, value, receiver);
},
});
}
const student = validated({ name: "Amina", score: 88 }, {
score: (v) => Number.isInteger(v) && v >= 0 && v <= 100,
});
student.score = 91;
try {
student.score = 150;
} catch (err) {
console.log(err.message); // Invalid value for score: 150
}
function createStore(initial) {
const listeners = new Set();
let scheduled = false;
const changed = new Set();
const state = new Proxy({ ...initial }, {
set(obj, key, value) {
if (obj[key] === value) return true;
obj[key] = value;
changed.add(key);
if (!scheduled) {
scheduled = true;
queueMicrotask(() => { // batch: one notification per tick
scheduled = false;
const keys = [...changed];
changed.clear();
for (const fn of listeners) fn(state, keys);
});
}
return true;
},
});
return {
state,
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
};
}
const store = createStore({ present: 0, absent: 0 });
const unsubscribe = store.subscribe((s, keys) => {
console.log(`render: present=${s.present} absent=${s.absent} (changed: ${keys.join(", ")})`);
});
store.state.present = 25;
store.state.absent = 3; // both changes -> a single render
setTimeout(() => {
store.state.present = 26; // -> render: present=26 absent=3 (changed: present)
queueMicrotask(unsubscribe); // runs after that render, then stops listening
queueMicrotask(() => { store.state.absent = 4; }); // no render: nobody is subscribed
}, 0);Key points
- Proxy traps intercept operations;
Reflectperforms the default behaviour. - Validation, logging and reactivity are classic Proxy use cases.
- Batch notifications with a microtask to avoid redundant renders.
Exercise
Connect createStore to a page: show present and absent counts, with buttons that change them. Then make the store deep-reactive, so changing state.classes[0].count also triggers a render.
Show solution
Try the exercise yourself first — then compare your approach with this one.
The deep version wraps nested objects in Proxies too, lazily — the get trap returns a reactive wrapper whenever it reads an object, so state.classes[0].count += 1 passes through a set trap and schedules a render. The page subscribes once and redraws whenever anything changes.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Reactive attendance</title>
<script type="module" src="main.js"></script>
</head>
<body>
<p>Present: <strong id="present"></strong> · Absent: <strong id="absent"></strong></p>
<button id="arrive">Student arrived</button>
<button id="absentee">Mark absent</button>
<ul id="classes"></ul>
<button id="form4">+1 in Form 4</button>
<p>Renders: <span id="renders">0</span></p>
</body>
</html>function createDeepStore(initial) {
const listeners = new Set();
let scheduled = false;
const notify = () => {
if (scheduled) return;
scheduled = true;
queueMicrotask(() => {
scheduled = false;
for (const fn of listeners) fn(state);
});
};
const cache = new WeakMap();
const reactive = (obj) => {
if (cache.has(obj)) return cache.get(obj);
const proxy = new Proxy(obj, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver);
return value !== null && typeof value === "object" ? reactive(value) : value;
},
set(target, key, value, receiver) {
const ok = Reflect.set(target, key, value, receiver);
notify();
return ok;
},
});
cache.set(obj, proxy);
return proxy;
};
const state = reactive(structuredClone(initial));
return { state, subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)) };
}
const store = createDeepStore({
present: 0,
absent: 0,
classes: [{ name: "Form 4", count: 30 }, { name: "Form 3", count: 28 }],
});
let renders = 0;
store.subscribe((s) => {
document.querySelector("#present").textContent = s.present;
document.querySelector("#absent").textContent = s.absent;
document.querySelector("#classes").replaceChildren(
...s.classes.map((c) => Object.assign(document.createElement("li"), { textContent: `${c.name}: ${c.count}` })),
);
document.querySelector("#renders").textContent = ++renders;
});
document.querySelector("#arrive").addEventListener("click", () => { store.state.present++; });
document.querySelector("#absentee").addEventListener("click", () => { store.state.absent++; });
document.querySelector("#form4").addEventListener("click", () => { store.state.classes[0].count += 1; }); // nested change
store.state.present = 0; // trigger the first render