cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Make state reactiveUse a Proxy set trap, lazy nested wrapping, and a microtask scheduler so many writes cause one render.
  • 02
    Render through virtual nodesReturn safe lightweight trees from a view function and mount them as DOM without parsing user data as HTML.
  • 03
    Patch 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.

Plain definition

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.

Real-life analogyUpdate one score on a scoreboard

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: queueMicrotask batches 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 THROUGH

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

Proxy state with a microtask schedulerPop out in the code editor (opens in a new tab)JavaScript
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.

Step through one batched reactive render
Step 0 of 10Ready
Your turn: follow the blue line

Follow four state writes that happen in one turn. The proxy observes each write, but queueMicrotask turns them into one render.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Arrays and direct replacement both work

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 NODES

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

A view returns a virtual-node treePop out in the code editor (opens in a new tab)JavaScript
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.

Create DOM safely from virtual nodesPop out in the code editor (opens in a new tab)JavaScript
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"));
Do not parse user data as HTML

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

PATCH

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

A tiny index-based patch functionJavaScript
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.

Step through a DOM patch that preserves an input
Step 0 of 8Ready
Your turn: follow the blue line

Watch a second render update only changed DOM. The patch keeps an input focused while text and attributes update around it.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Index diff reorder bugPop out in the code editor (opens in a new tab)JavaScript
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 LISTENER

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

Where each event-routing piece belongs
PieceWhy it existsLink back
data-actionA declarative command label in the rendered DOMEvent delegation
closest()Clicks on nested text or spans still find the buttonDOM navigation
One root listenerRe-rendered and newly appended buttons still bubble to the same handlerBubbling and capturing
State writeThe handler changes state; the scheduler handles renderingState 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 APP

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

Live mini framework
Delegated listener used by the mini appJavaScript
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;  }});
Mounting the tiny appJavaScript
const app = mountTinyReactiveUi(document.querySelector("#app"), (snapshot) => {
  console.log(snapshot.renderCount + ":" + snapshot.count + ":" + snapshot.todos.join("|"));
});
app.burst();
queueMicrotask(() => {
  app.replaceTodos();
  queueMicrotask(() => app.destroy());
});
Real DOM outputone root listener
State count0
Todosopen:Read proxy traps
Renders0
DOM operations0
Listeners1
Active elementnone
Try it yourself

The mounted app owns one delegated listener on its root. Render count: 0. DOM operations this update: 0.

Use the buttons inside the mounted app too. They are handled by one root listener with data-action routing, not listeners rebound after each render.

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.

Which framework piece is doing the work?
  • `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.
Try it yourself
0 of 6 correct

Sort each line or behavior into the piece of the tiny framework that owns it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Tiny framework versus production framework
Added layerWhat it solves
ComponentsReusable view/state units with props, local state, and children instead of one global view(state).
Lifecycle and cleanupMount, update, unmount, effects, refs, and cleanup hooks so resources do not leak.
Better schedulingPriorities, transitions, idle work, suspense, error recovery, and avoiding long tasks.
CompilersTemplate analysis, static hoisting, keyed updates, hydration, and warnings before code reaches the browser.
Fine-grained reactivitySome 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.
Rendering strategies compared
StrategyHow it updatesGood fitMain risk
innerHTML re-renderRebuild a string and replace a containerSimple demos and trusted static markupParses HTML, can create XSS risk, destroys focus, selection, listeners, and input state
Virtual DOM diff/patchCompare old and new virtual nodes, then mutate only changed DOMComponent frameworks, safe text rendering, predictable full-view rerendersNeeds a diff algorithm; index-based lists mis-handle reorders without keys
Fine-grained signalsTrack which computation read each value and re-run only those computationsHighly interactive UIs with many small derived valuesMore dependency-tracking machinery and rules around reads/effects

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the batched render

Run the scheduler mentally and type the final line.

Starter codePop out in the code editor (opens in a new tab)JavaScript
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));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeRemove an attribute during patch

    Which DOM method belongs in the missing branch?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function updateProps(element, oldProps, newProps) {
      for (const name of Object.keys(oldProps)) {
        if (!(name in newProps)) {
          // remove the old attribute here
        }
      }
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeExplain the keyed-list bug

      Predict the output of the index-based reorder model.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const before = ["a", "b"];
      const typedByDomPosition = ["typed for a", ""];
      const after = ["b", "a"];
      console.log(after[0] + ":" + typedByDomPosition[0]);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRoute a delegated action

        Type the command string that a delegated listener would route.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const action = "toggle";
        const id = "todo-4";
        console.log(action + ":" + id);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePatch text only

          Predict the final text after a minimal text-node patch.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          let text = "Count: 0";
          const next = "Count: 1";
          if (text !== next) text = next;
          console.log(text);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Tiny reactive UI quiz · 8 questionsScore: first tries count
            1. Question 1 of 8Why does the Proxy example use queueMicrotask in scheduleRender?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this batching code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              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));

              Choose an answer to see the explanation.

            3. Question 3 of 8Why does the get trap lazily wrap nested objects?

              Choose an answer to see the explanation.

            4. Question 4 of 8What is the safer way to render user text in this lesson's renderer?

              Choose an answer to see the explanation.

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

            6. Question 6 of 8What is the classic bug when diffing lists only by index?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            7. Question 7 of 8Why does the mini app put one click listener on the root?

              Choose an answer to see the explanation.

            8. 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 Reflect preserves normal assignment behavior.
            • queueMicrotask batches 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.

            CompleteFrontend Clear concepts. Working examples.