Build a tiny reactive UI
Build a tiny reactive JavaScript UI with Proxy state, virtual nodes, DOM diffing, event delegation, and a runnable todo app.
- 01Make state reactiveUse a Proxy set trap, lazy nested wrapping, and a microtask scheduler so many writes cause one render.
- 02Render through virtual nodesReturn safe lightweight trees from a view function and mount them as DOM without parsing user data as HTML.
- 03Patch and route behaviorDiff old and new trees, preserve live DOM state, and use one delegated listener for re-rendered controls.
A tiny framework in one page
You have already studied closures, Proxy, Reflect, observer-style signals, state management, microtasks, DOM modification, delegation, event delegation, MutationObserver, and application architecture. This capstone combines them into a real, tiny UI framework.
A reactive UI connects state writes to rendering: when code changes state, a scheduler asks a view function for the next UI tree, diffs it against the old tree, and patches the real DOM.
The framework is intentionally small: one reactive function, one virtual-node helper, one patcher, and one delegated event listener. It is not a replacement for React, Vue, Svelte, or Solid. It is a microscope for seeing the loop those tools automate.
When one team scores, a cricket scoreboard changes that score instead of repainting the whole board. A reactive UI compares the next view with the old one and changes only what differs.
- In real life: A score changes
- In JavaScript: A state write reaches the Proxy
- In real life: The board waits for the next update
- In JavaScript:
queueMicrotaskbatches a render - In real life: Compare the new board with the old one
- In JavaScript: Virtual nodes are diffed
- In real life: Replace only the score number
- In JavaScript: Patch updates needed DOM nodes
Where the analogy stops: A scoreboard has simple numbers. A renderer must also manage lists, inputs, cleanup, and many kinds of DOM nodes.
This lesson also points forward to Build a jQuery-style library for packaging small APIs and to XSS for the security lesson that follows from unsafe rendering.
Reactive state with Proxy
STEP THROUGHThe reactive(obj) wrapper stands in front of an object. Reads go through get; writes go through set. The set trap forwards with Reflect.set, then schedules a render. A queued flag keeps several assignments in the same call stack from causing several renders.
const cache = new WeakMap();let queued = false;let renders = 0;const writes = []; function reactive(target) { const wrap = (value) => { if (typeof value !== "object" || value === null) return value; if (cache.has(value)) return cache.get(value); const proxy = new Proxy(value, { get(target, property, receiver) { return wrap(Reflect.get(target, property, receiver)); }, set(target, property, value, receiver) { writes.push(String(property)); const ok = Reflect.set(target, property, value, receiver); if (ok) scheduleRender(); return ok; }, }); cache.set(value, proxy); return proxy; }; return wrap(target);} function scheduleRender() { if (queued) return; queued = true; queueMicrotask(() => { queued = false; renders += 1; console.log("render " + renders + ": " + state.count + "/" + state.user.name + "/" + state.items.join("|")); });} const state = reactive({ count: 0, user: { name: "Ada" }, items: [] });state.count += 1;state.user.name = "Grace";state.items.push("mutated");state.items = ["replaced"];console.log("sync renders " + renders);queueMicrotask(() => { console.log("after microtask " + renders); console.log(writes.join(","));});Read the important lines in order. Line 10 creates the proxy. Line 14 sees every write, line 17 schedules rendering, and line 28 refuses to queue a second render. Lines 38 through 41 prove direct property assignment, nested assignment, array mutation, and direct array replacement all reach a trap.
Follow four state writes that happen in one turn. The proxy observes each write, but queueMicrotask turns them into one render.
script
let renderQueued = false;let renderCount = 0; function reactive(target) { const wrap = (value) => { if (typeof value !== "object" || value === null) return value; if (cache.has(value)) return cache.get(value); const proxy = new Proxy(value, { get(target, property, receiver) { return wrap(Reflect.get(target, property, receiver)); }, set(target, property, value, receiver) { const ok = Reflect.set(target, property, value, receiver); if (ok) scheduleRender(); return ok; }, }); cache.set(value, proxy); return proxy; }; return wrap(target);} function scheduleRender() { if (renderQueued) return; renderQueued = true; queueMicrotask(() => { renderQueued = false; render(); });} function render() { renderCount += 1; console.log("render " + renderCount);} const state = reactive({ count: 0, profile: { name: "Ada" }, items: [] });render();state.count += 1;state.profile.name = "Grace";state.items.push("Learn diffing");state.items = ["Ship tiny UI"];queueMicrotask(() => console.log("final renders " + renderCount));state.items.push("mutated") writes to the proxied array, so the array's index and length operations go through traps. state.items = ["replaced"] writes the parent property, so it also schedules a render. The next time state.items is read, the get trap lazily wraps the new array.
Signals from the observer pattern lesson are more fine-grained: a computation records exactly which signal it read and only that computation re-runs. Our Proxy version is coarse: any state write schedules the whole view. That is simpler to build and easier to inspect, but it can do more work.
Templating and rendering
VIRTUAL NODESA view function should be boring: state in, description out. The h(tag, props, ...children) helper returns a lightweight object. It does not create a DOM node yet, so it is safe to call during a render pass and easy to compare later.
function h(tag, props = {}, ...children) { return { tag, props, children: children.flat() };} function view(state) { return h( "section", { class: "todo-card" }, h("h2", {}, "Count: ", state.count), h("button", { type: "button", "data-action": "increment" }, "+1"), h("ul", {}, state.items.map((item) => h("li", {}, item.title))) );} const state = { count: 1, items: [{ title: "Render with text nodes" }] };console.log(JSON.stringify(view(state), null, 2));Line 1 defines the tiny template helper. Line 5 names the view. Line 8 creates a heading with text children. Line 9 puts behavior metadata in data-action, not a callback. Line 10 maps data to list items. The final log shows plain JSON-like objects, not DOM nodes.
Mounting turns that tree into real DOM. Text children become Text nodes. Element children become document.createElement calls with attributes and child nodes. Notice that user text is never concatenated into innerHTML.
function createDom(vnode) { if (typeof vnode === "string" || typeof vnode === "number") { return document.createTextNode(String(vnode)); } const element = document.createElement(vnode.tag); for (const [name, value] of Object.entries(vnode.props ?? {})) { element.setAttribute(name, String(value)); } for (const child of vnode.children) element.append(createDom(child)); return element;} const userText = "<img src=x onerror=alert(1)>";const tree = { tag: "p", props: {}, children: ["Hello ", userText] };const root = document.querySelector("#app");root.replaceChildren(createDom(tree));console.log(root.textContent);console.log(root.querySelector("img"));Template strings plus innerHTML are tempting, but untrusted strings can contain event handler attributes, dangerous URLs, or markup you did not intend. If you must render HTML, escape or sanitize it on a trusted path. The next module's XSS lesson goes deeper.
Diffing the DOM
PATCHReplacing the whole root is easy, but it loses browser-owned state: focus, selection, scroll positions, media playback, and uncontrolled input values. A patcher compares old and new virtual nodes, then mutates the existing DOM node when it can.
function patch(oldVNode, newVNode, domNode) { if (typeof oldVNode !== "object" || typeof newVNode !== "object") { if (String(oldVNode) !== String(newVNode)) domNode.nodeValue = String(newVNode); return domNode; } if (oldVNode.tag !== newVNode.tag) { const replacement = createDom(newVNode); domNode.replaceWith(replacement); return replacement; } updateProps(domNode, oldVNode.props, newVNode.props); const oldChildren = oldVNode.children; const newChildren = newVNode.children; const shared = Math.min(oldChildren.length, newChildren.length); for (let index = 0; index < shared; index += 1) { patch(oldChildren[index], newChildren[index], domNode.childNodes[index]); } for (let index = shared; index < newChildren.length; index += 1) { domNode.append(createDom(newChildren[index])); } for (let index = oldChildren.length - 1; index >= newChildren.length; index -= 1) { domNode.childNodes[index].remove(); } return domNode;}The patch has five jobs. Text nodes update nodeValue. Different tags are replaced. Props are changed or removed. Shared child indexes recurse. Extra children are appended or removed from the end. That is enough to understand the core loop, not enough to be production-ready.
Watch a second render update only changed DOM. The patch keeps an input focused while text and attributes update around it.
script
return h("section", {}, h("h2", {}, "Count: " + count), h("label", {}, "Draft ", h("input", { placeholder: "keeps value" })), h("p", { class: "status", "data-status": status }, status), );} const root = document.querySelector("#app");let oldTree = view(0, "idle");root.replaceChildren(createDom(oldTree));const input = root.querySelector("input");input.value = "still typing";input.focus(); const nextTree = view(1, "saved");patch(oldTree, nextTree, root.firstChild);oldTree = nextTree;console.log(root.querySelector("input") === input);console.log(document.activeElement === input);console.log(input.value);The replay uses a mutation counter inside the renderer: created nodes, text changes, attribute changes, replacements, and child inserts/removals. A real browser MutationObserver could count external mutations too, but counting our own operations makes the patch decisions explicit.
Keyed lists fix the classic reorder bug
Index-based diffing assumes the first old child corresponds to the first new child. That is false when a list reorders. The demo below intentionally shows the bug: an input value typed for item A appears under item B because the DOM node at index 0 was reused for the new item at index 0.
const root = document.createElement("div");const before = h("ul", {}, h("li", { "data-id": "a" }, h("span", {}, "A"), h("input", {})), h("li", { "data-id": "b" }, h("span", {}, "B"), h("input", {})),);root.append(createDom(before));root.querySelector("input").value = "typed for A"; const after = h("ul", {}, h("li", { "data-id": "b" }, h("span", {}, "B"), h("input", {})), h("li", { "data-id": "a" }, h("span", {}, "A"), h("input", {})),);patch(before, after, root.firstChild);const firstRow = root.querySelector("li");console.log(firstRow.querySelector("span").textContent + ":" + firstRow.querySelector("input").value);A keyed patch would build a map from stable keys such as data-id to old DOM nodes, move matching nodes to the new order, and create or remove only missing keys. Real frameworks invest heavily in this because forms and animations depend on identity.
Event delegation
ONE LISTENERThe view creates buttons every render, but the framework does not bind listeners to those buttons. Instead, one listener stays on the root. It uses event.target.closest("[data-action]") to find the command element and root.contains to guard the boundary.
| Piece | Why it exists | Link back |
|---|---|---|
data-action | A declarative command label in the rendered DOM | Event delegation |
closest() | Clicks on nested text or spans still find the button | DOM navigation |
| One root listener | Re-rendered and newly appended buttons still bubble to the same handler | Bubbling and capturing |
| State write | The handler changes state; the scheduler handles rendering | State management |
Delegation is not magic. It works because click events bubble. It can be blocked by stopPropagation(), and not every event bubbles. For focus, use focusin or capture, as the dedicated event delegation lesson explains.
Wire it into a todo app
LIVE APPThe playground mounts the real tiny framework into an empty DOM node. Try three synchronous writes: two counter assignments and one array push happen before the microtask, yet the render count rises by one. Then replace the todo array to prove direct assignment works too.
const root = document.querySelector("#app");root.addEventListener("click", (event) => { const button = event.target.closest("[data-action]"); if (!button || !root.contains(button)) return; if (button.dataset.action === "increment") { state.count += 1; } if (button.dataset.action === "toggle") { const todo = state.todos.find((item) => item.id === Number(button.dataset.id)); if (todo) todo.done = !todo.done; }});const app = mountTinyReactiveUi(document.querySelector("#app"), (snapshot) => {
console.log(snapshot.renderCount + ":" + snapshot.count + ":" + snapshot.todos.join("|"));
});
app.burst();
queueMicrotask(() => {
app.replaceTodos();
queueMicrotask(() => app.destroy());
});0open:Read proxy traps001noneThe mounted app owns one delegated listener on its root. Render count: 0. DOM operations this update: 0.
The app's flow is the architecture loop from the previous lesson: input event → state change → scheduled render → new virtual tree → patch → stable DOM. The root listener is the controller edge. The view is a pure description. The state object is the single source of truth.
`set(target, property, value)` schedules one microtask render.`h('li', {}, todo.title)` creates a plain object tree.`domNode.nodeValue = String(newVNode)` updates only changed text.`event.target.closest('[data-action]')` routes clicks from re-rendered buttons.`element.append(createDom(newChild))` handles a child that did not exist before.`const nextTree = view(state)` describes the latest UI from state.
Sort each line or behavior into the piece of the tiny framework that owns it.
What real frameworks add
Our framework is a teaching tool. It has no component model, no lifecycle cleanup, no keyed diff, no hydration, no error boundary, no accessibility warnings, no compiler, and no devtools. Real frameworks add those layers because production UI has to survive partial failures, streaming data, forms, animations, and thousands of nodes.
| Added layer | What it solves |
|---|---|
| Components | Reusable view/state units with props, local state, and children instead of one global view(state). |
| Lifecycle and cleanup | Mount, update, unmount, effects, refs, and cleanup hooks so resources do not leak. |
| Better scheduling | Priorities, transitions, idle work, suspense, error recovery, and avoiding long tasks. |
| Compilers | Template analysis, static hoisting, keyed updates, hydration, and warnings before code reaches the browser. |
| Fine-grained reactivity | Some frameworks update exact computations or DOM bindings instead of diffing a whole virtual tree. |
Fine-grained reactive systems such as signals can skip a whole-tree virtual DOM pass by tracking exactly which DOM binding or computation read each value. Virtual DOM systems often trade that precision for simple views and strong component composition. Both are valid designs when the trade-offs are explicit.
Misconceptions
- “Proxy makes every nested object reactive immediately.” Not in this design. Nested objects are wrapped lazily when read.
- “Array mutation cannot be reactive.” Array methods write indexes and length through the proxy. Direct replacement also works through the parent property.
- “Virtual DOM means HTML strings.” A virtual node is a data object. DOM creation can and should use text nodes for user data.
- “Diffing always preserves identity correctly.” Index diffing preserves positions, not items. Reordered lists need keys.
- “One delegated listener is always better.” A single stable button is fine with a direct listener. Delegation shines for repeated or dynamic children.
| Strategy | How it updates | Good fit | Main risk |
|---|---|---|---|
innerHTML re-render | Rebuild a string and replace a container | Simple demos and trusted static markup | Parses HTML, can create XSS risk, destroys focus, selection, listeners, and input state |
| Virtual DOM diff/patch | Compare old and new virtual nodes, then mutate only changed DOM | Component frameworks, safe text rendering, predictable full-view rerenders | Needs a diff algorithm; index-based lists mis-handle reorders without keys |
| Fine-grained signals | Track which computation read each value and re-run only those computations | Highly interactive UIs with many small derived values | More dependency-tracking machinery and rules around reads/effects |
Practice exercises
5 EXERCISESRun the scheduler mentally and type the final line.
let queued = false;
let renders = 0;
function schedule() {
if (queued) return;
queued = true;
queueMicrotask(() => {
queued = false;
renders += 1;
console.log("render " + renders);
});
}
schedule();
schedule();
schedule();
queueMicrotask(() => console.log("final " + renders));It prints render 1 and then final 1. Three schedule calls in one turn become one render.
Which DOM method belongs in the missing branch?
function updateProps(element, oldProps, newProps) {
for (const name of Object.keys(oldProps)) {
if (!(name in newProps)) {
// remove the old attribute here
}
}
}if (!(name in newProps)) {
element.removeAttribute(name);
}Removing absent attributes prevents stale data-*, ARIA, or class values from lingering after a render.
Predict the output of the index-based reorder model.
const before = ["a", "b"];
const typedByDomPosition = ["typed for a", ""];
const after = ["b", "a"];
console.log(after[0] + ":" + typedByDomPosition[0]);The output is b:typed for a. A keyed diff would move the DOM node for a instead of blindly reusing index 0.
Type the command string that a delegated listener would route.
const action = "toggle";
const id = "todo-4";
console.log(action + ":" + id);The routed command is toggle:todo-4, the same shape a root listener would use to find and update a todo.
Predict the final text after a minimal text-node patch.
let text = "Count: 0";
const next = "Count: 1";
if (text !== next) text = next;
console.log(text);It prints Count: 1. Text patching changes the text node instead of replacing the parent element.
Check your understanding
8 QUESTIONSQuestion 1 of 8Why does the Proxy example use
queueMicrotaskinscheduleRender?Choose an answer to see the explanation.
Question 2 of 8What does this batching code print?
Read the code, then predictlet queued = false; let renders = 0; function schedule() { if (queued) return; queued = true; queueMicrotask(() => { queued = false; renders += 1; console.log("render " + renders); }); } schedule(); schedule(); schedule(); queueMicrotask(() => console.log("final " + renders));Choose an answer to see the explanation.
Question 3 of 8Why does the
gettrap lazily wrap nested objects?Choose an answer to see the explanation.
Question 4 of 8What is the safer way to render user text in this lesson's renderer?
Choose an answer to see the explanation.
Question 5 of 8Which DOM state does an in-place patch preserve better than replacing the whole root?
Choose an answer to see the explanation.
Question 6 of 8What is the classic bug when diffing lists only by index?
Read the code, then predictconst before = ["a", "b"]; const typedByDomPosition = ["typed for a", ""]; const after = ["b", "a"]; console.log(after[0] + ":" + typedByDomPosition[0]);Choose an answer to see the explanation.
Question 7 of 8Why does the mini app put one click listener on the root?
Choose an answer to see the explanation.
Question 8 of 8What would real frameworks add beyond this tiny renderer?
Choose an answer to see the explanation.
Key takeaways
- A Proxy can schedule rendering from writes while
Reflectpreserves normal assignment behavior. queueMicrotaskbatches several synchronous state writes into one render of the latest state.- Virtual nodes are plain descriptions; safe DOM creation uses elements, attributes, and text nodes instead of parsing user data as HTML.
- A patch function updates text, props, replacements, and children while preserving DOM nodes that still match.
- Index diffing is not enough for reordered lists; real frameworks use keys to preserve item identity.
- Event delegation keeps re-rendered and newly added controls working through one stable root listener.
One-line summary: a reactive UI framework turns state writes into scheduled view descriptions, then patches the smallest useful part of the DOM.
Up next: XSS, where the safe text-rendering rule becomes a full security practice.