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's
settrap and a microtask so many state changes give one render. - 02Describe 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.
- 03Update 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:
- Closures, Proxy, and Reflect.
- The observer pattern and state management.
- Microtasks.
- Changing the DOM and MutationObserver.
- Delegation and event delegation.
- App architecture.
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:
- A scheduler waits until your current code finishes.
- A view function builds a new description of the page.
- The framework compares it with the old description.
- It changes only the parts of the real page that are different.
The framework has four small parts:
- a
reactivefunction that notices every change to the state; - an
hfunction that describes a piece of the page as a plain object; - a
patchfunction 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.
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:
queueMicrotaskwaits 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 THROUGHThe 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
gettrap runs on every read, likestate.count. - The
settrap runs on every write, likestate.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.
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
settrap. 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 asettrap. - 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 printafter microtask 1and the list of written names,count,name,0,length,items.
The step-through below runs a very similar program. It also renders once at the start, before any writes.
Follow four state writes in one run of code. The Proxy sees 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));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.
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
Setfor 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.
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 seesqueuedon line 4 and stops. So there is one render:render 1. - Line 14 calls
schedule()from a timer.setTimeoutruns its function later, after the first render has finished. By thenqueuedisfalseagain, so it printsrender 2. - Line 15 runs last and prints
total 2.
So the queued flag only joins writes made in the same run of code.
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 NODESA 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.
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
hhelper. - 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-actionattribute 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
mapto describe onelifor 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.
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.
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
hrefonly forhttps:links. This link starts withjavascript:, which would run code when clicked. So line 10 sets the textBlocked linkinstead. - 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.
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
PATCHThe 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.
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
h2andp, 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
patchagain. - 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.
Watch a second render change only what differs. The patch keeps an input focused while the text and an attribute change 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 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.
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.
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 Ainto 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-idchange, from A to B. - Line 15 prints
B:typed for A. Row 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 LISTENERThe 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.closeststarts at the clicked element and walks up through its parents. It returns the first one that has adata-actionattribute.root.contains(button)makes sure that button is inside the root.
#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.
| Piece | What it does | Lesson to review |
|---|---|---|
data-action | It 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 listener | It catches clicks from new and rebuilt buttons, because their clicks bubble up to it. | Bubbling and capturing |
| A state write | The 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.
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 APPThe 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.
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 app has one click listener, on its root. Renders so far: 0. Page changes in the last render: 0.
This is the loop from the app architecture lesson:
- A click reaches the root listener.
- The listener changes the state.
- The Proxy asks for a render, and the scheduler queues one.
- The view builds a new virtual tree.
patchupdates 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.
`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.
Put each line of code in the part of the tiny framework it belongs to.
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 they add | What it does |
|---|---|
| Components | They 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 away | They run code when a piece is added, updated, or removed, so timers and listeners don't keep running. |
| Better scheduling | They decide which updates go first, and split long work so the page doesn't freeze. |
| Compilers | They check and prepare page code before it reaches the browser, and warn you about mistakes. |
| Exact updates | Some 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.
pushwrites an index andlengththrough 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.
| Way | How it updates | When it fits | What can go wrong |
|---|---|---|---|
Rebuild with innerHTML | It 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 tree | It 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. |
| Signals | Each 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 EXERCISESRead the code without running it. Type the last line it prints.
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, then final 1. Three schedule() calls in one run of code give one render.
Which DOM method call belongs where the comment is?
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 old attributes stops a stale data-*, ARIA, or class value from staying on the element after a render.
This code imitates patching a reordered list by position. Type what it prints.
const before = ["a", "b"];
const typedByDomPosition = ["typed for a", ""];
const after = ["b", "a"];
console.log(after[0] + ":" + typedByDomPosition[0]);It prints b:typed for a. A patch that uses keys would move a's row instead of reusing the first row.
Type the command string this code prints.
const action = "toggle";
const id = "todo-4";
console.log(action + ":" + id);It prints toggle:todo-4. A root listener would read this action and id, then find and update that todo.
This code imitates patching one piece of text. Type what it prints.
let text = "Count: 0";
const next = "Count: 1";
if (text !== next) text = next;
console.log(text);It prints Count: 1. A text patch changes the text node and leaves its parent element in place.
Check your understanding
8 QUESTIONSQuestion 1 of 8Why does
scheduleRenderin the Proxy example usequeueMicrotask?Choose an answer to see the explanation.
Question 2 of 8What does this 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 wrap a nested object in its own Proxy the first time it is read?Choose an answer to see the explanation.
Question 4 of 8What is the safe way to show user text with this lesson's renderer?
Choose an answer to see the explanation.
Question 5 of 8What does patching in place keep that rebuilding the whole page loses?
Choose an answer to see the explanation.
Question 6 of 8This code imitates patching a reordered list by position. What does it print?
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 small app put one click listener on the root?
Choose an answer to see the explanation.
Question 8 of 8What do real frameworks add beyond this tiny one?
Choose an answer to see the explanation.
Key takeaways
- A Proxy's
settrap can ask for a render on every write.Reflect.setstill does the normal write. queueMicrotaskand aqueuedflag 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
textContentfor 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.