AdvancedJavaScript · Lesson 3 of 9

Proxy, Reflect & Building Reactivity

Intercept property access with Proxy and build a tiny reactive store that updates the page automatically.

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.

reactive.jsJavaScript
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);
Runs in your browser · JavaScript

Key points

  • Proxy traps intercept operations; Reflect performs 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.

index.htmlHTML
<!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>
main.jsJavaScript
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

Check your understanding

  1. What is a Proxy "trap"?

  2. Why call Reflect.set(target, key, value, receiver) inside a set trap?

  3. Three properties change in the same tick. With microtask batching, how many renders happen?

  4. Why doesn't a shallow Proxy notice state.classes[0].count = 5?

Ask AI