Skip to main content

Build JavaScript Proxy Reactive State: 855 Bytes, No Framework

Javascript proxy reactive state in under a kilobyte: an 80-line get/set trap that tracks reads, batches writes, and quietly breaks on one property name.

9 min read
Dashboard-style cover showing a JavaScript Proxy tracking property reads and batching writes into a single microtask flush, at 855 bytes

TL;DR A JavaScript Proxy is a wrapper that reports every property read to a listener and queues every property write instead of applying it immediately — that pair of traps is the entire mechanism behind javascript proxy reactive state: dependency tracking, batched updates, and automatic re-renders, no framework required. Mador, an open-source library, implements this in roughly 80 lines and ships at 855 bytes minified (488 bytes gzipped, measured directly from the published file), against ~140KB for React plus ReactDOM. Reading its source also surfaces a real bug worth knowing before you copy the pattern: a substring-based dependency check that can misfire on unrelated property names.

Most explanations of “reactive state” start from a framework’s internals, which means starting from thousands of lines you have to trust. Mador is small enough to read start to finish in five minutes, which makes it a better teaching example than any framework’s source tree — every mechanism is visible, and every trade-off it makes is a deliberate line you can point to. This post traces that source line by line: how the get/set traps build a dependency graph, how writes get batched into one DOM pass, and where the whole approach quietly breaks.

If you’ve measured what an abstraction costs before — what a dyn Trait fat pointer costs in Rust, or what pointer compression bought Node.js — this is the same exercise for the reactivity layer sitting under every modern frontend.

How JavaScript Proxy Reactive State Actually Works

A Proxy wraps an object and lets you intercept fundamental operations on it — property reads (get), property writes (set), and others. Mador’s entire dependency tracker is one get trap:

JavaScript
get(obj, prop) {
  const fullPath = path.concat(prop).join(".");
  activeEffect?.(fullPath);

  const val = Reflect.get(obj, prop);
  if (val !== null && typeof val === "object" && !Array.isArray(val)) {
    return state(val, path.concat(prop));
  }
  return val;
}

activeEffect is a module-level variable that’s null most of the time. Right before Mador computes a binding’s displayed value, it swaps in a real function — one that pushes whatever path it’s given into a deps array — then calls the binding’s valueFn(store). Every property that function touches fires this get trap, which reports its own dot-joined path ("cart.count", "user.name") to activeEffect. When the call returns, deps holds exactly the properties that specific function read — no static analysis, no compiler step, just recording what actually happened during one real execution.

The recursive part matters too: if a read returns a plain object, the trap wraps that object in a new Proxy carrying its own path prefix before returning it, so a read like state.user.profile.name reports the full "user.profile.name" path, not just "user". Arrays are deliberately excluded from that wrapping (!Array.isArray(val)) — a decision that comes back later.

The set trap is the mirror image: it computes the same dot-joined path, compares old and new values, and — if they actually differ — pushes that path onto a pendingPaths array instead of writing to the DOM directly. It also supports functional updates (state.count = c => c + 1), a small convenience layered on top of the same trap.

Why It Matters: 855 Bytes vs a 140KB React Bundle

Mador’s published file on jsDelivr is exactly 855 bytes; gzip -9 on that same file compresses it to 488 bytes. For comparison, using each library’s current npm release measured the same way (Bundlephobia, checked today):

LibraryMinified sizeGzippedReactivity model
Mador 0.x855 B488 BRaw Proxy traps, no virtual DOM
Preact 10.29.811,768 B4,837 BVirtual DOM diffing
petite-vue 0.4.116,458 B6,963 BVue 3’s Proxy-based reactivity, subset
Alpine.js 3.17.154,486 B19,038 BProxy-based reactivity + directive parser
React 18.3.1 + ReactDOM 18.3.1140,443 B45,552 BVirtual DOM + Fiber reconciler

That’s not a fair fight, and it isn’t supposed to be one — Mador does a fraction of what any of those libraries do. But the gap is instructive: the reactive core — the part that decides “which DOM update does this state change trigger” — doesn’t inherently need a virtual DOM, a diffing algorithm, or a component model. Frameworks bundle those in because they solve real problems at scale; a page with a handful of stateful widgets is paying for all of it anyway.

Bar chart on a log scale comparing minified library sizes: Mador at 855 bytes, Preact at 11.8 kilobytes, petite-vue at 16.5 kilobytes, Alpine.js at 54.5 kilobytes, and React plus ReactDOM at 140.4 kilobytes

How Writes Batch Into a Single Microtask

Naively, you might expect each set trap firing to trigger an immediate DOM update — but that would mean three separate DOM passes for state.x = 1; state.y = 2; state.z = 3 inside one logical update. Mador avoids that with queueMicrotask:

JavaScript
function w(fn) {
  pendingPaths = [];
  fn(store);
  if (!isQueued) {
    isQueued = true;
    queueMicrotask(() => {
      isQueued = false;
      runners = runners.filter((r) => r.run(pendingPaths));
    });
  }
}

fn(store) runs synchronously and fires every set trap it triggers, accumulating paths into pendingPaths. The microtask itself is only scheduled once per flush cycle (isQueued guards against scheduling it twice), so no matter how many properties a single write() call touches, the DOM only gets touched once, after the callback returns but before the browser’s next paint. This is the same batching idea React’s automatic batching and Vue’s reactivity scheduler both implement — Mador just does it in six lines instead of a scheduler module.

The runners.filter(...) line does double duty: run() returns false when a runner’s CSS selector no longer matches any element in the document, and filter drops those runners from the array permanently. That’s the library’s entire garbage-collection story — no explicit unmount() or cleanup function, just “if your element left the DOM, stop checking it.”

Flow diagram showing write() accumulating changed paths, flushing once via queueMicrotask, then filtering runners by DOM-selector match before re-running only the bindings whose dependencies changed

How to Add Reactive Bindings to a Page in 4 Steps

  1. Create the store. const [read, write] = mador({ count: 0 }) returns a tuple: a function to bind DOM elements, and a function to mutate state.

  2. Bind a selector to a render function. read(selector, updateFn, valueFn) takes a CSS selector, an element-update callback, and a “value function” — reads inside the value function are what get tracked as dependencies:

    JavaScript
    read(".counter", (el, count) => {
      el.textContent = "Count: " + count;
    }, (state) => state.count);
  3. Mutate state through write(), never directly. write(state => { state.count++; }) — because only the set trap inside a write() call records a path into pendingPaths; a raw property assignment outside that call still mutates the object but never gets scheduled onto a runner.

  4. Let stale bindings clean themselves up. Remove the bound element from the DOM and its runner’s next run() call returns false the moment document.querySelectorAll(selector) comes back empty — no manual teardown needed.

That’s the whole public surface: two functions, no build step, no compiler, distributed as a native ES module.

What Breaks: The Substring Dependency-Matching Bug

Here’s the part that only shows up from reading the actual source rather than the README. A runner decides whether to re-run itself by checking if any changed path matches its recorded dependencies:

JavaScript
matches(changedPaths) {
  if (!this.deps) return true;
  return this.deps.some((p) =>
    changedPaths.some((d) => d.includes(p) || p.includes(d)),
  );
}

d.includes(p) and p.includes(d) are plain JavaScript string includes calls — substring containment, not path-segment equality or ancestor/descendant comparison. That means a dependency on a top-level property named count will match a changed path like "accounts.count", because the string "accounts.count" literally contains the substring "count". The runner has no way to distinguish “this is the same property” from “this string happens to appear inside that one.”

Two dependency path strings, count and accounts.count, connected by a red false-match arrow labeled substring containment, illustrating how a plain string.includes check confuses an unrelated property for a real dependency

In practice this causes over-rendering, not incorrect output — a binding re-runs when it didn’t need to, wasting a querySelectorAll and a value recomputation, but it always recomputes from the real current state, so the DOM never shows a stale value. The fix a hardened version would need is splitting both paths on . and comparing segment arrays for a true prefix relationship, instead of comparing the joined strings. It’s the kind of bug that a small, readable core makes cheap to find and expensive to ignore if you fork this pattern into something bigger.

Reactive Library Size Comparison

The size table above is worth reading alongside what each library actually promises, since bytes alone undersell what the bigger ones are buying:

LibraryWhat you get beyond reactivity
MadorNothing else — two functions, string-based dependency matching, no component model
petite-vueVue 3’s actual reactivity system (Proxy-based, exact dependency tracking), directives, computed values
Alpine.jsFull directive language (x-show, x-for, transitions), plugin ecosystem, magic properties
PreactVirtual DOM, JSX, hooks, a React-compatible component model
React + ReactDOMFiber concurrent rendering, server components, a vast ecosystem

A micro-library like Mador isn’t competing with any of these on features — it’s a demonstration that the reactive primitive is cheap, and everything past it is a deliberate, sizable investment in correctness and ergonomics at scale.

FAQ

How does a JavaScript Proxy know which DOM bindings depend on which state?

It doesn’t know in advance — it discovers it by intercepting get. Before running the function that computes a binding’s value, the library swaps in a listener; every property access the function makes fires the Proxy’s get trap, which records that property’s path. Whatever paths got touched during that one synchronous call become that binding’s dependency list, rebuilt fresh on every run.

Why does Mador batch writes with queueMicrotask instead of updating the DOM immediately?

Because state mutations usually arrive in a burst — several state.x = y assignments inside one write() callback — and updating the DOM after each individual assignment would mean redundant reflows for a change the caller intended as one logical update. queueMicrotask collects every path touched during that callback and flushes exactly once, after the callback returns but before the browser paints.

Why aren’t array mutations deeply reactive in a Proxy-based state library like this?

The recursive-proxying step explicitly excludes arrays — typeof val === 'object' && !Array.isArray(val) — so plain objects get wrapped in a nested Proxy on every read, but arrays don’t. Reassigning the whole array through the top-level setter is tracked normally; mutating one object stored inside an array bypasses the wrapping that would have tracked reads on that nested object’s own fields.

What is the substring dependency-matching bug in path-based reactivity?

It happens when a library compares dependency paths as strings with a containment check — one path “includes” another — rather than checking they’re the same path or a real ancestor/descendant of each other split on the separator. A property literally named “count” will then match a changed path like “accounts.count”, because the string “accounts.count” contains the substring “count”, even though the two have nothing to do with each other.

How much smaller is a Proxy-based reactive core than React or Alpine.js?

Minified, the core described here is 855 bytes against roughly 140KB for React 18 plus ReactDOM and about 54.5KB for Alpine.js — two to three orders of magnitude smaller, because it skips a virtual DOM, a reconciler, and a directive parser entirely. It also does far less: no component model, no lifecycle hooks, no server rendering story, and no dependency-graph correctness guarantees beyond what a raw string match gives you.

Should you use a Proxy-based micro-library like this in production?

For a marketing page or a small progressively-enhanced widget where the alternative is hand-written querySelector and manual DOM writes, yes — it buys real dependency tracking for less than a kilobyte. For an application with real component composition, routing, or a large team, the missing guarantees (exact dependency matching, deep array reactivity, error boundaries) are exactly the work a bigger framework has already done, and reinventing it under time pressure is the more expensive path.

Sources

  • marsbos/mador — the full source read and traced in this post (MIT licensed).
  • MDN: Proxy — the get/set trap semantics this pattern relies on.
  • Bundlephobia — minified and gzipped size figures for Preact, petite-vue, Alpine.js, React, and ReactDOM, checked against each package’s current published release.

If you’ve been thinking about frontend cost in terms of render performance rather than bundle weight, Core Web Vitals optimization and React performance techniques cover the other half of that budget, and SvelteKit vs Next.js is the same size-vs-features trade-off one layer up, at the framework level.

Frequently asked questions

Share this article:
X LinkedIn

Google Search · Preferred sources

Prefer this site on Google

If you already read this writing, add umesh-malik.com as a Preferred Source. Google can then highlight it with a preferred badge in Top Stories, AI Overviews, and AI Mode — for you, not as a site-wide ranking boost.

Keep reading

Get new posts on AI, Claude Code & LLMs

New deep-dives on AI engineering, Claude Code, and developer tooling — follow along however you prefer.