completefrontend
Open lab My progress
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's set trap and a microtask so many state changes give one render.
  • 02
    Describe the page with plain objectsReturn a tree of plain objects from a view, and build page elements from it without reading user text as HTML.
  • 03
    Update the page and handle clicksCompare old and new trees, keep the elements that match, and handle every button with one listener on the root.

A tiny framework in one page

In this lesson you build a very small UI framework. A UI framework is code that keeps a web page in step with your data. React and Vue are big ones. Yours fits on one page. It uses ideas from these earlier lessons:

Plain definition

A reactive UI updates the page by itself when your data changes. The data is called the state. Each time the state changes, this happens:

  1. A scheduler waits until your current code finishes.
  2. A view function builds a new description of the page.
  3. The framework compares it with the old description.
  4. It changes only the parts of the real page that are different.

The framework has four small parts:

  • a reactive function that notices every change to the state;
  • an h function that describes a piece of the page as a plain object;
  • a patch function that updates the real page;
  • one click listener that handles every button.

It can't replace React, Vue, Svelte, or Solid. Its job is to show you the loop those tools run for you.

Real-life analogyUpdate one score on a scoreboard

At a cricket ground, a team scores a run. The scoreboard operator waits until the ball stops moving. Then the operator looks at the new score and the board. Only the score number changes. The team names and the rest of the board stay as they are.

In real life: A team scores
In JavaScript: Your code writes a new value into the state
In real life: The operator waits until the ball stops moving
In JavaScript: queueMicrotask waits until your current code finishes
In real life: The operator compares the new score with the board
In JavaScript: The new description is compared with the old one
In real life: Only the score number is changed
In JavaScript: Only the changed parts of the page are updated

Two later lessons build on this one. Build a jQuery-style library shows how to package small tools like these. XSS shows what goes wrong when a page shows user text in an unsafe way.

Reactive state with Proxy

STEP THROUGH

The page should update whenever the state changes. So the framework must notice every change. It does this with a Proxy. A Proxy is an object that stands in front of another object. Every read and every write goes through the Proxy first. A function the Proxy runs for one kind of action is called a trap:

  • The get trap runs on every read, like state.count.
  • The set trap runs on every write, like state.count = 1.

Inside set, Reflect.set does the normal write. Then the trap calls scheduleRender(). To render means to build the page again from the state.

We don't want one render for every write. So scheduleRender uses queueMicrotask. It runs a function as a microtask: a short job that runs right after the current code finishes. A flag called queued remembers that a render is already waiting.

Four writes, one renderPop 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(","));});

Here are the important lines:

  • Line 10 makes the Proxy.
  • Line 14 is the set trap. It runs on every write.
  • Line 17 asks for a render.
  • Line 28 stops early when a render is already queued.
  • Lines 38 to 41 make four kinds of write: a plain property, a property of a nested object, an array push, and a whole new array. Each one reaches a set trap.
  • Line 42 prints sync renders 0. No render has happened yet.
  • The render runs next. It prints render 1: 1/Grace/replaced. Then lines 43 and 44 print after microtask 1 and the list of written names, count,name,0,length,items.
Four state writes in one turn: state.count += 1, state.user.name = "Grace", state.items.push("mutated"), and state.items = ["replaced"]. One arrow, marked microtask, leads to a single card: render 1: 1/Grace/replaced.state.count += 1state.user.name = "Grace"state.items.push("mutated")state.items = ["replaced"]4 writes in one runmicrotaskrender 1: 1/Grace/replacedone render
All four writes happen before the code stops running. The scheduler queues only one microtask, so the view renders once, with the newest values.

The step-through below runs a very similar program. It also renders once at the start, before any writes.

Step through four writes and one render
Step 0 of 10Ready
Your turn: follow the blue line

Follow four state writes in one run of code. The Proxy sees each write, but queueMicrotask turns them into one render.

Running in
  1. script
Next: line 1
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));
CallStoreChangeResultBlue line: runs next. 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.
Changing an array and replacing it both work

Reading state.items goes through the get trap. The trap wraps the array in its own Proxy the first time it is read. So state.items.push("mutated") writes through that array Proxy. It writes index 0 and then length, and each write asks for a render.

state.items = ["replaced"] is a write to items on the state object itself. So it asks for a render too. The new array gets its own Proxy the next time state.items is read.

The observer pattern lesson showed signals. A signal remembers exactly which code read it. When it changes, only that code runs again. Our Proxy version is simpler: any write makes the whole view render again. It is easier to build and to follow, but it can do more work than needed.

The next example has no Proxy. It keeps a set of listeners: functions to call after each change.

Listen for changes, count open todos, then stopPop out in the code editor (opens in a new tab)JavaScript
const listeners = new Set();const state = { todos: [{ title: "Read", done: false }] }; function subscribe(listener) {  listeners.add(listener);  return () => listeners.delete(listener);}function setDone(done) {  state.todos[0].done = done;  for (const listener of listeners) listener();}function openCount() {  return state.todos.filter((todo) => !todo.done).length;} const stop = subscribe(() => console.log("open " + openCount()));setDone(true);stop();setDone(false);
  • Line 1 makes an empty Set for the listeners.
  • Line 5 adds a listener. Line 6 returns a function that removes it again.
  • Line 9 changes the todo. Line 10 then calls every listener.
  • Line 12, openCount(), counts the todos that are not done.
  • Line 16 adds a listener and keeps the remove function in stop.
  • Line 17 marks the todo done. The listener prints open 0.
  • Line 18 calls stop(). So line 19 changes the todo, but nothing prints.

Calling stop() matters. A screen that is gone but still listening keeps running code. It also keeps its data in memory.

A later timer gets its own renderPop 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();setTimeout(() => schedule(), 0);setTimeout(() => console.log("total " + renders), 20);
  • Lines 12 and 13 call schedule() twice in a row. The second call sees queued on line 4 and stops. So there is one render: render 1.
  • Line 14 calls schedule() from a timer. setTimeout runs its function later, after the first render has finished. By then queued is false again, so it prints render 2.
  • Line 15 runs last and prints total 2.

So the queued flag only joins writes made in the same run of code.

Calculate a value instead of storing it twice

A derived value is one you can calculate from other state, like the number of open todos. Keep todos as the one place the data lives, and calculate the open count from it. If you store both todos and openCount, one update can change one and forget the other. Then they disagree. Save a calculated value for reuse only when you have measured that the calculation is slow.

Templating and rendering

VIRTUAL NODES

A view is a function that takes the state and returns a description of the page. It doesn't touch the real page.

The description is built with a small helper, h(tag, props, ...children). It returns a plain object with three fields:

  • tag: the element name, like "h2".
  • props: the element's attributes, like { class: "todo-card" }.
  • children: what goes inside it, as text or more of these objects.

This plain object is a virtual node: a description of one element, not the element itself. A virtual node with children inside it is a virtual tree. The ...children part collects all the remaining arguments into an array. Making these objects is cheap and changes nothing on the page. Later, two of them are easy to compare.

A view returns a tree of plain objectsPop 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 is the h helper.
  • Line 5 starts the view. It takes the state.
  • Line 9 describes a heading. Its children are the text "Count: " and the count.
  • Line 10 describes a button. Its data-action attribute names a command, "increment". The button holds a name, not a function. You'll see who reads that name in the event section.
  • Line 11 uses map to describe one li for each item.
  • Line 16 prints the whole tree as JSON. It is all plain objects. No page elements exist yet.

Mounting means building the real page elements from the tree for the first time. The function below does it:

  • Text and numbers become Text nodes with document.createTextNode. A Text node shows its text exactly as written. The browser never reads it as HTML.
  • Other nodes become elements with document.createElement. Their props become attributes, and their children are built the same way.

User text is never pasted into innerHTML, which would read it as HTML.

Build page elements 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"));
  • Lines 2 and 3 turn a string or number into a Text node.
  • Line 9 builds each child and adds it to the element.
  • Line 13 is user text that looks like an image tag.
  • Line 16 builds the tree and puts it into #app.
  • Line 17 prints the text, angle brackets and all: Hello <img src=x onerror=alert(1)>.
  • Line 18 prints null. No image element was made.
Show user text as text, and check links firstPop out in the code editor (opens in a new tab)JavaScript
const user = {  name: "<img src=x onerror=alert(1)>",  link: "javascript:alert(1)",};const label = document.createElement("p");label.textContent = user.name;const link = document.createElement("a");const url = new URL(user.link, "https://example.test/");if (url.protocol === "https:") link.href = url.href;else link.textContent = "Blocked link";console.log(label.textContent);console.log(link.textContent);
  • Line 6 sets textContent. That sets plain text, never HTML. So the angle brackets stay text.
  • Line 8 reads the link with new URL.
  • Line 9 sets href only for https: links. This link starts with javascript:, which would run code when clicked. So line 10 sets the text Blocked link instead.
  • Lines 11 and 12 print the name as plain text, then Blocked link.

Don't write a loop that copies every user value onto an element as an attribute. An attribute like onclick runs code, and an unchecked link can too.

Don't read user text as HTML

Building HTML with template strings and innerHTML is quick. But text from users can hold an attribute that runs code, a link that runs code, or tags you didn't expect. This kind of attack is called XSS (cross-site scripting). If you really must show HTML, clean it first with a trusted tool. The XSS lesson covers this in detail.

Diffing the DOM

PATCH

The simplest render throws away the whole page and builds it again. That is easy, but the browser keeps some things on each element, and they are lost:

  • which input has the cursor (the focus);
  • which text is selected;
  • how far a box is scrolled;
  • whether a video is playing;
  • text typed into an input that the state doesn't store.

So instead, a patch function compares the old virtual tree with the new one. Comparing two trees is called diffing. Then patch changes the existing page elements only where the trees differ, and keeps every element it can.

A small patch function that compares by positionJavaScript
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 six jobs. createDom is the mounting function from above, and updateProps updates attributes:

  • Line 2: if both nodes are text, line 3 changes nodeValue (the text inside a Text node) only when the text differs.
  • Line 7: if the tags differ, like h2 and p, it builds a new element and swaps it in.
  • Line 13: if the tags match, it keeps the element and updates its attributes. Attributes that are gone get removed.
  • Line 19: for each child that both trees have at the same position, it calls patch again.
  • Line 22: it adds children the new tree has but the old one didn't.
  • Line 25: it removes children the old tree had but the new one doesn't.

That is enough to learn the idea. A real framework does much more.

Left window, before: Count: 0, an input holding still typing, and the status idle. An arrow marked patch leads to the right window, after patch: Count: 1, the same input still holding still typing, and the status saved.beforeCount: 0still typingidlepatchafter patchCount: 1still typingsavedchangedsame nodechanged
The patch changes the count text and the status. The input stays the same element, so it keeps its focus and the text you typed.
Step through a patch that keeps an input
Step 0 of 8Ready
Your turn: follow the blue line

Watch a second render change only what differs. The patch keeps an input focused while the text and an attribute change around it.

Running in
  1. script
Next: line 1
  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);
CallStoreChangeResultBlue line: runs next. 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 step-through counts the page changes: 2 text changes and 1 attribute change. Our renderer counts its own work: elements made, text changes, attribute changes, swaps, and children added or removed. A browser MutationObserver could count page changes too. Counting inside the renderer shows exactly what patch chose to do.

Count the values that changedPop out in the code editor (opens in a new tab)JavaScript
const before = ["Count: 0", "Saved"];
const after = ["Count: 1", "Saved"];
const changed = after.filter((value, index) => value !== before[index]).length;
console.log("changed values " + changed);

Line 3 compares the two lists position by position and keeps the values that differ. Only "Count: 0" and "Count: 1" differ. So line 4 prints changed values 1.

"Change only what differs" doesn't mean the view is skipped. This renderer still calls the view and builds a whole new description. It only skips page work for values that match, like "Saved". When building the description is itself slow, big frameworks track which values each part reads, or prepare the work ahead of time.

Reordered lists need keys

Our patch compares children by position. It assumes the first old child and the first new child are the same item. That is wrong when a list changes order. The code below shows the bug on purpose.

Patching a reordered list by positionPop 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);
  • Line 2 describes a list with row A, then row B. Each row has an input.
  • Line 7 types typed for A into the first input, in row A.
  • Line 9 describes the new order: B first, then A.
  • Line 13 patches. The first row keeps its input. Only its letter and its data-id change, from A to B.
  • Line 15 prints B:typed for A. Row B now shows the text typed for A.
Left window, before: row A with an input holding typed for A, then row B with an empty input. An arrow marked patch leads to the right window, after patch: row B now holds typed for A, and row A is empty. The result is B:typed for A.beforeAtyped for AB(empty)you type into Apatchafter patchBtyped for AA(empty)B:typed for A
The new order is B, then A. Patching by position reuses the first row's input, so B now shows the text typed for A.

The fix is a key: a fixed id for each item, like data-id. A patch that uses keys matches old and new rows by key, not by position:

  • It moves each matching row to its new place.
  • It builds rows only for new keys.
  • It removes rows only for keys that are gone.

Real frameworks work hard on this. Forms and animations break when a row loses track of which item it belongs to.

Event delegation

ONE LISTENER

The view makes new buttons on many renders. But the framework never adds a listener to those buttons. Instead, one listener sits on the root element, #app. This is event delegation: one listener on a parent handles events from everything inside it.

It works because clicks bubble. After you click a button, the click event travels up through each parent element, all the way to the root. There the listener does two checks:

  • event.target.closest("[data-action]") finds the button. closest starts at the clicked element and walks up through its parents. It returns the first one that has a data-action attribute.
  • root.contains(button) makes sure that button is inside the root.
A window called #app root holds three buttons: Increment, Add todo, and Done next to Read proxy traps. An arrow marked click leads from the window to one tag, one listener.#app rootIncrementAdd todoDoneRead proxy trapsnew buttons need no listenerclickonelistener
A click on any button inside #app bubbles up to the one listener on the root. Buttons made by a later render work too, with no listener of their own.

The table shows each piece of the event code and what it is for. The last column names the lesson to review.

The pieces that send a click to the right command
PieceWhat it doesLesson to review
data-actionIt names the command on the button, like increment.Event delegation
closest()It finds the button even when you click text or an icon inside it.DOM navigation
One root listenerIt catches clicks from new and rebuilt buttons, because their clicks bubble up to it.Bubbling and capturing
A state writeThe listener only changes the state. The scheduler then handles the render.State management

Delegation has limits. A child can call stopPropagation(), and then the click never reaches the root. And some events don't bubble at all, like focus. For focus, listen for focusin instead, or listen while the event travels down. The event delegation lesson explains both.

Stop listening when the view goes awayPop out in the code editor (opens in a new tab)JavaScript
const listeners = new Set();function subscribe(listener) {  listeners.add(listener);  return () => listeners.delete(listener);}function emit() {  for (const listener of listeners) listener();}const stop = subscribe(() => console.log("render"));emit();stop();emit();console.log("remaining " + listeners.size);
  • Line 2 starts subscribe. Line 4 returns a function that removes the listener.
  • Line 9 adds a listener and keeps that remove function in stop.
  • Line 10 calls the listeners, so it prints render.
  • Line 11 calls stop(). So line 12 prints nothing.
  • Line 13 prints remaining 0.

A listener has an owner: the view that added it. So the mount function should return a cleanup function. When the view goes away, call cleanup first, then remove the page elements. The same rule fits listeners, timers, observers, sockets, and shared stores: remove them when the view that made them goes away.

Wire it into a todo app

LIVE APP

The playground below runs the real tiny framework inside an empty element. Try these:

  • Three writes at once adds 1 to the count twice and pushes one todo. All three happen before the microtask, so the render count goes up by only one.
  • Replace the todo array puts a whole new array into state.todos. It renders too.
  • Type, then add 1 types into the input, then adds 1 to the count. The input keeps its text and focus.
The tiny framework, live
The app's one root listenerJavaScript
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;  }});
Start the app, then remove itJavaScript
const app = mountTinyReactiveUi(document.querySelector("#app"), (snapshot) => {
  console.log(snapshot.renderCount + ":" + snapshot.count + ":" + snapshot.todos.join("|"));
});
app.burst();
queueMicrotask(() => {
  app.replaceTodos();
  queueMicrotask(() => app.destroy());
});
The real pageone root listener
Count0
Todosopen:Read proxy traps
Renders0
Page changes0
Listeners1
Has focusnone
Try it yourself

The app has one click listener, on its root. Renders so far: 0. Page changes in the last render: 0.

The buttons inside the app work too. One listener on the root handles them all by reading data-action. No listener is added after a render.

This is the loop from the app architecture lesson:

  1. A click reaches the root listener.
  2. The listener changes the state.
  3. The Proxy asks for a render, and the scheduler queues one.
  4. The view builds a new virtual tree.
  5. patch updates the page and keeps the elements that match.

Each part has one job. The root listener takes in clicks. The view only describes the page. The state object is the one place the data lives.

Which part of the framework does this?
  • `set(target, property, value)` queues one render in a microtask.
  • `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

Put each line of code in the part of the tiny framework it belongs to.

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 for learning. Real apps need more. Here is some of what it doesn't have:

  • Components: small reusable pieces of page, each with its own state.
  • Cleanup that runs by itself when a piece of page goes away.
  • Lists matched by key.
  • Hydration: taking over HTML that a server already sent.
  • A way to show a fallback when part of the page throws an error.
  • Warnings about accessibility problems.
  • A compiler that prepares the page code before it reaches the browser.
  • Developer tools for looking inside the app.

Real apps have forms, animations, data that arrives in pieces, and thousands of elements. Parts of them fail. These extra pieces help them keep working.

The table shows what each added piece does in a real framework.

What real frameworks add to the tiny one
What they addWhat it does
ComponentsThey split the page into small reusable pieces, each with its own inputs, state, and children, instead of one big view(state).
Cleanup when a piece goes awayThey run code when a piece is added, updated, or removed, so timers and listeners don't keep running.
Better schedulingThey decide which updates go first, and split long work so the page doesn't freeze.
CompilersThey check and prepare page code before it reaches the browser, and warn you about mistakes.
Exact updatesSome frameworks update only the exact part of the page that read a value, instead of comparing a whole tree.

Frameworks built on signals don't compare whole trees. Each signal remembers which part of the page read it, and only that part updates. Frameworks with a virtual tree are less exact, but their views are simple functions that are easy to combine. Both designs are fine when you know what each one costs.

Misconceptions

  • "A Proxy makes every nested object reactive right away." Not in this design. A nested object gets its own Proxy the first time it is read.
  • "Changing an array can't trigger a render." It can. push writes an index and length through the array's Proxy. Putting in a whole new array works too, through the state object's Proxy.
  • "A virtual tree is a string of HTML." No. A virtual node is a plain object. The page is built from it with Text nodes and elements, so user text stays text.
  • "Comparing trees always keeps each item with its own element." Not when it compares by position. A reordered list needs keys.
  • "One listener on the root is always better." For one button that never changes, a listener on the button is fine. Delegation helps most with many buttons, or buttons that come and go.

The table compares three ways to update a page. Read each row from left to right.

Three ways to update the page
WayHow it updatesWhen it fitsWhat can go wrong
Rebuild with innerHTMLIt builds one HTML string and puts it in place of everything inside a container.It fits small demos and HTML you wrote yourself.User text can run code (XSS). Focus, selected text, listeners, and typed input are all lost.
Patch from a virtual treeIt compares the old and new trees of plain objects, then changes only the elements that differ.It fits frameworks with components, and pages that show user text safely.You need a compare step. Comparing by position breaks reordered lists unless you use keys.
SignalsEach value remembers which code read it, and only that code runs again.It fits busy pages with many small calculated values.There is more tracking code, and more rules about where you read values.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict how many renders run

Read the code without running it. Type the last line it prints.

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 that is gone

    Which DOM method call belongs where the comment is?

    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 reordered-list bug

      This code imitates patching a reordered list by position. Type what it prints.

      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 · PracticeBuild a command for the root listener

        Type the command string this code prints.

        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 · ChallengeChange only the text

          This code imitates patching one piece of text. Type what it prints.

          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 scheduleRender in the Proxy example use queueMicrotask?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this 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 wrap a nested object in its own Proxy the first time it is read?

              Choose an answer to see the explanation.

            4. Question 4 of 8What is the safe way to show user text with this lesson's renderer?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does patching in place keep that rebuilding the whole page loses?

              Choose an answer to see the explanation.

            6. Question 6 of 8This code imitates patching a reordered list by position. What does it print?

              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 small app put one click listener on the root?

              Choose an answer to see the explanation.

            8. Question 8 of 8What do real frameworks add beyond this tiny one?

              Choose an answer to see the explanation.

            Key takeaways

            • A Proxy's set trap can ask for a render on every write. Reflect.set still does the normal write.
            • queueMicrotask and a queued flag join many writes in one run of code into one render of the newest state.
            • A virtual node is a plain object that describes an element. Build the page from it with elements and Text nodes, never by reading user text as HTML.
            • A patch changes text, attributes, and children, and keeps every page element that still matches.
            • Comparing children by position breaks when a list changes order. Real frameworks match items by key.
            • One listener on the root handles clicks from new and rebuilt buttons, because clicks bubble up to it.
            • Calculate derived values from one place in the state. Give every listener a way to stop.
            • Use Text nodes or textContent for user text, and check links before you use them.

            One-line summary: a reactive UI turns state changes into one render that builds a description of the page, then changes only the parts of the real page that differ.

            Up next: XSS. There, the rule "show user text as text" becomes a full security habit.

            CompleteFrontend Clear concepts. Working examples.