Build your own tiny React
Write a tiny React in plain JavaScript, step by step: createElement, a fiber work loop with time slicing, keyed reconciliation, commit, useState and useEffect.
- 01Build React's core loopWrite createElement, a fiber work loop that pauses every 5 ms, keyed reconciliation and a commit, in plain JavaScript.
- 02Write hooks from scratchKeep useState and useEffect on the fiber in call order, with an update queue and effects that run after the commit.
- 03Map every part to React's sourceName the React file that does each job, and say what React does there that a tiny version doesn't.
A tiny React, built step by step
You've read React's source one part at a time: elements, fibers, the work loop, reconciliation, the commit, hooks. In this last lesson you put the parts together by writing your own small React in plain JavaScript, with no React on the page at all. Here is where you'll end up. Type in the search box and press a +1:
// tiny-react.js, step 7 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };} // Step 2: DOM nodes.function createDom(fiber) { const dom = fiber.type === "TEXT" ? document.createTextNode("") : document.createElement(fiber.type); updateDom(dom, {}, fiber.props); return dom;} function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }} function eventName(prop) { return prop.slice(2).toLowerCase();} // Step 3: fibers, and a work loop that stops every 5 ms.let currentRoot = null;let workInProgressRoot = null;let workInProgress = null;let deadline = 0; const channel = new MessageChannel();channel.port1.onmessage = () => { deadline = performance.now() + 5; flushEffects(); workLoop();}; function requestWork() { channel.port2.postMessage(null);} function shouldYield() { return performance.now() >= deadline;} function render(element, container, onCommit) { startRender({ dom: container, props: { children: [element] }, onCommit });} function startRender({ dom, props, onCommit }) { const report = { slices: 0, fibers: 0, changes: [] }; workInProgressRoot = { dom, props, onCommit, report, alternate: currentRoot, child: null, deletions: [] }; workInProgress = workInProgressRoot; requestWork();} function workLoop() { if (!workInProgress) return; workInProgressRoot.report.slices++; while (workInProgress && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); workInProgressRoot.report.fibers++; } if (workInProgress) requestWork(); else commitRoot();} function performUnitOfWork(fiber) { beginWork(fiber); if (fiber.child) return fiber.child; for (let next = fiber; next; next = next.return) { if (next.sibling) return next.sibling; } return null;} function beginWork(fiber) { if (typeof fiber.type === "function") { renderingFiber = fiber; hookIndex = 0; fiber.hooks = []; reconcileChildren(fiber, toChildren(fiber.type(fiber.props))); } else { fiber.dom ??= createDom(fiber); reconcileChildren(fiber, fiber.props.children); }} // Step 4: reconciliation, matched by key.function reconcileChildren(fiber, elements) { const oldByKey = new Map(); for (let old = fiber.alternate?.child; old; old = old.sibling) { oldByKey.set(old.key ?? old.index, old); } const placeAll = !fiber.dom && fiber.flags === "Placement"; let lastPlacedIndex = 0; let previous = null; elements.forEach((element, index) => { const id = element.key ?? index; const old = oldByKey.get(id); const same = old !== undefined && old.type === element.type; if (same) oldByKey.delete(id); const child = { type: element.type, key: element.key, props: element.props, index, dom: same ? old.dom : null, alternate: same ? old : null, return: fiber, child: null, sibling: null, flags: null, }; if (!same || placeAll || old.index < lastPlacedIndex) child.flags = "Placement"; else lastPlacedIndex = old.index; if (previous) previous.sibling = child; else fiber.child = child; previous = child; }); fiber.deletions = [...oldByKey.values()];} function report(change, fiber) { const name = fiber.type === "TEXT" ? JSON.stringify(fiber.props.nodeValue) : (fiber.type.name ?? fiber.type) + (fiber.key === null ? "" : " " + fiber.key); workInProgressRoot.report.changes.push(change + " " + name);} function changedProps(fiber) { const before = fiber.alternate.props; return Object.keys({ ...before, ...fiber.props }).some((name) => name !== "children" && before[name] !== fiber.props[name]);} // Step 5: commit, all at once.function commitRoot() { const root = workInProgressRoot; commitWork(root); currentRoot = root; workInProgressRoot = null; if (pendingEffects.length || pendingUnmounts.length) requestWork(); root.onCommit?.(root.report);} function commitWork(fiber) { for (const old of fiber.deletions) commitDeletion(old); if (fiber.dom && fiber.return) { if (fiber.flags === "Placement") { hostParent(fiber).insertBefore(fiber.dom, nodeAfter(fiber)); report(fiber.alternate ? "move" : "insert", fiber); } if (fiber.alternate && changedProps(fiber)) { updateDom(fiber.dom, fiber.alternate.props, fiber.props); report("update", fiber); } } for (let child = fiber.child; child; child = child.sibling) commitWork(child); if (fiber.hooks) { for (const hook of fiber.hooks) if (hook.queue) hook.queue.pending.splice(0, hook.applied); pendingEffects.push(fiber); } fiber.flags = null; fiber.alternate = null;} function hostParent(fiber) { let parent = fiber.return; while (!parent.dom) parent = parent.return; return parent.dom;} function nodeAfter(fiber) { for (let sibling = fiber.sibling; sibling; sibling = sibling.sibling) { const node = firstPlacedNode(sibling); if (node) return node; } return fiber.return.dom ? null : nodeAfter(fiber.return);} function firstPlacedNode(fiber) { if (fiber.flags === "Placement") return null; if (fiber.dom) return fiber.dom; for (let child = fiber.child; child; child = child.sibling) { const node = firstPlacedNode(child); if (node) return node; } return null;} function commitDeletion(fiber) { for (const node of topNodes(fiber)) node.remove(); report("delete", fiber); collectUnmounts(fiber);} function topNodes(fiber) { if (fiber.dom) return [fiber.dom]; const nodes = []; for (let child = fiber.child; child; child = child.sibling) nodes.push(...topNodes(child)); return nodes;} // Step 6: useState, with an update queue.let renderingFiber = null;let hookIndex = 0; function useState(initialState) { const old = renderingFiber.alternate?.hooks[hookIndex]; const queue = old ? old.queue : { pending: [], dispatch: null }; let state = old ? old.state : typeof initialState === "function" ? initialState() : initialState; for (const action of queue.pending) { state = typeof action === "function" ? action(state) : action; } queue.dispatch ??= (action) => { queue.pending.push(action); startRender(currentRoot ?? workInProgressRoot); }; renderingFiber.hooks[hookIndex++] = { state, queue, applied: queue.pending.length }; return [state, queue.dispatch];} // Step 7: useEffect, after the commit.let pendingEffects = [];let pendingUnmounts = []; function useEffect(effect, deps) { const old = renderingFiber.alternate?.hooks[hookIndex]; const changed = !old || deps === undefined || deps.some((dep, i) => !Object.is(dep, old.deps[i])); renderingFiber.hooks[hookIndex++] = { deps, effect: changed ? effect : null, cleanup: old?.cleanup };} function collectUnmounts(fiber) { for (const hook of fiber.hooks ?? []) if (hook.cleanup) pendingUnmounts.push(hook.cleanup); for (let child = fiber.child; child; child = child.sibling) collectUnmounts(child);} function flushEffects() { const unmounts = pendingUnmounts; const fibers = pendingEffects; pendingUnmounts = []; pendingEffects = []; for (const cleanup of unmounts) cleanup(); for (const fiber of fibers) { for (const hook of fiber.hooks) if (hook.effect && hook.cleanup) hook.cleanup(); } for (const fiber of fibers) { for (const hook of fiber.hooks) { if (!hook.effect) continue; hook.cleanup = hook.effect(); hook.effect = null; } }}The open file, tiny-react.js, is the whole library: 273 lines, with createElement, render, a fiber work loop that pauses every 5 ms, keyed reconciliation, a commit, useState and useEffect. The app.js tab is the shop: components written with h, which is createElement, since there's no JSX compiler here (JSX compiles to the same calls). Each +1 keeps its count as the list filters, because rows are matched by key.
You'll write it in seven steps. Each step adds one job to tiny-react.js, runs live, and ends with the file in React 19.3.1 that does the same job. This lesson is inspired by Rodrigo Pombo's Build your own React, which walks a similar path; the code and steps here are this course's own, with keys, a scheduler built on MessageChannel, a commit that moves nodes, and useEffect.
tiny-react.js on the left. The file on the right is where React does the same job, in a lesson you've already read.Train fans build working models of real stations. The model has fewer trains and no ticket office, but the tracks run the same way, and a model train follows the same signals. Once you've laid every track yourself, you can walk into the real station and know why everything is where it is.
- In real life: The real station, with its timetables, signals and staff
- In React: React, with lanes, a scheduler, events and hydration
- In real life: A model on a table, with the same tracks and the same platforms
- In React: tiny-react.js, with the same render, reconcile and commit
- In real life: A model train that runs on the same rules as the real one
- In React: The same shop code runs on both
- In real life: Building it yourself teaches you where every track goes
- In React: Writing it teaches you what each React file is for
React is a loop that turns descriptions of the page into changes to the page. createElement describes; the work loop turns each element into a fiber, one at a time, and pauses so the browser gets a turn; reconciliation compares each fiber with the one from the last render; the commit changes the DOM in one go; and hooks keep state and effects on the fiber, in call order. Everything else React does makes this loop faster, smarter or safer.
Step 1: createElement makes plain objects
A React element is a plain object that describes one piece of the page: what to make (type), how to tell it apart from its siblings (key), and everything else, including its children (props). The React elements lesson took one apart. Here is the first part of tiny-react.js:
// Step 1: elements describe the page.
function createElement(type, config, ...children) {
const { key = null, ...props } = config ?? {};
props.children = toChildren(children);
return { type, key: key === null ? null : String(key), props };
}
function toChildren(values) {
return [values]
.flat(Infinity)
.filter((value) => value !== null && value !== undefined && typeof value !== "boolean")
.map((value) => (typeof value === "object" ? value : createTextElement(value)));
}
function createTextElement(text) {
return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };
}- Line 2 takes the element's type, its props object (
config), and any number of children, gathered by the...childrenrest parameter. - Line 3 takes
keyout of the props, the way React does, and keeps the rest inprops. - Line 4 puts the children in
props.children, and line 5 returns the object. A key is stored as a string, as React does too. - Lines 8 to 13:
toChildrenflattens arrays, such as one made bymap(line 10), dropsnull,undefined,trueandfalse(line 11), and turns strings and numbers into text elements (line 12). - Lines 15 to 17: a text element has the type
"TEXT"and its text innodeValue, so later steps can treat every child the same way.
Run it. The app.js tab builds a product card and prints it as JSON:
// tiny-react.js, step 1 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };}In app.js, line 1 names createElement h, a short name many tiny libraries use. Line 7 passes two children, "Price: " and the number 4; the JSON shows both as TEXT elements, the second with nodeValue "4". Line 8 is the pattern {soldOut && <p>…</p>}: false is dropped, so the section has only two children. Press Edit & run and change false on line 8 to true: a third child appears.
React's own element, made by jsx(), is the same idea, in ReactJSXElement.js:
// In prod, `ref` is a regular property and _owner doesn't exist.
element = {
// This tag allows us to uniquely identify this as a React Element
$$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type,
key,
ref,
props,
};Lines 7 to 11 are our three fields, plus ref. Line 4 adds one field we skipped: $$typeof, a symbol that a JSON string can't contain, so data from a server can't pass as an element. React also keeps text as plain strings in props.children instead of making text elements.
Step 2: render turns elements into DOM nodes
Now make the page. The simplest render walks the element tree and makes a DOM node for each element, children first, then puts the result in the container:
// Step 2: turn elements into DOM nodes, all in one go.
function render(element, container) {
const dom = createDom(element);
for (const child of element.props.children) render(child, dom);
container.appendChild(dom);
}
function createDom(element) {
const dom = element.type === "TEXT" ? document.createTextNode("") : document.createElement(element.type);
updateDom(dom, {}, element.props);
return dom;
}
function updateDom(dom, prevProps, nextProps) {
for (const [name, value] of Object.entries(prevProps)) {
if (name === "children" || nextProps[name] === value) continue;
if (name.startsWith("on")) dom.removeEventListener(eventName(name), value);
else if (!(name in nextProps)) dom[name] = "";
}
for (const [name, value] of Object.entries(nextProps)) {
if (name === "children" || prevProps[name] === value) continue;
if (name.startsWith("on")) dom.addEventListener(eventName(name), value);
else dom[name] = value;
}
}
function eventName(prop) {
return prop.slice(2).toLowerCase();
}- Lines 2 to 6: make this element's node (line 3), render each child into it (line 4, a recursive call), then add it to the container (line 5).
- Line 9: a text node for
TEXT, an element for anything else. - Lines 14 to 25:
updateDomcompares old props with new ones. The first loop removes old event listeners (line 17) and clears props that are gone (line 18). The second adds new listeners (line 22) and sets new or changed props (line 23). Here every prop is new, because line 10 passes{}as the old props. - Lines 27 to 29:
onClickbecomes the browser'sclick.
// tiny-react.js, step 2 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };} // Step 2: turn elements into DOM nodes, all in one go.function render(element, container) { const dom = createDom(element); for (const child of element.props.children) render(child, dom); container.appendChild(dom);} function createDom(element) { const dom = element.type === "TEXT" ? document.createTextNode("") : document.createElement(element.type); updateDom(dom, {}, element.props); return dom;} function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }} function eventName(prop) { return prop.slice(2).toLowerCase();}Press Add to cart. In app.js, addOne adds one and calls show, which empties the page and renders everything again: every node on the page is new, so anything the browser kept in the old ones, like focus or typed text, is gone. Then press Render 20,000 teas and watch the ticker. While render runs, nothing else on the page can run, so the ticker can't tick: one long piece of work holds the page until it's done. With enough rows you'll see it stop.
React's DOM code lives in ReactFiberConfigDOM.js, the host config the source tour showed. Updating a text node there is one line, just like ours:
export function commitTextUpdate(
textInstance: TextInstance,
oldText: string,
newText: string,
): void {
textInstance.nodeValue = newText;
}Line 6 sets nodeValue, the same property our updateDom sets for a TEXT element. The difference is when: React never calls this while it's working out what changed. That's the next three steps.
Step 3: fibers and a work loop that pauses
To keep the page responsive, the work has to come in small pieces, with a way to stop and come back. A recursive call can't stop halfway. So each element becomes a fiber: an object that remembers its DOM node and links to its first child, its next sibling and its parent, return. With those links, a plain loop can do one fiber at a time and always knows where to go next.
// Step 3: fibers, and a work loop that stops every 5 ms.
let workInProgressRoot = null;
let workInProgress = null;
let deadline = 0;
const channel = new MessageChannel();
channel.port1.onmessage = () => {
deadline = performance.now() + 5;
workLoop();
};
function requestWork() {
channel.port2.postMessage(null);
}
function shouldYield() {
return performance.now() >= deadline;
}
function render(element, container, onCommit) {
startRender({ dom: container, props: { children: [element] }, onCommit });
}
function startRender({ dom, props, onCommit }) {
const report = { slices: 0, fibers: 0, changes: [] };
workInProgressRoot = { dom, props, onCommit, report };
workInProgress = workInProgressRoot;
requestWork();
}
function workLoop() {
if (!workInProgress) return;
workInProgressRoot.report.slices++;
while (workInProgress && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
workInProgressRoot.report.fibers++;
}
if (workInProgress) requestWork();
else commitRoot();
}
function performUnitOfWork(fiber) {
beginWork(fiber);
if (fiber.child) return fiber.child;
for (let next = fiber; next; next = next.return) {
if (next.sibling) return next.sibling;
}
return null;
}
function beginWork(fiber) {
fiber.dom ??= createDom(fiber);
let previous = null;
for (const element of fiber.props.children) {
const child = { type: element.type, key: element.key, props: element.props, dom: null, return: fiber, child: null, sibling: null };
if (previous) previous.sibling = child;
else fiber.child = child;
previous = child;
}
}
function commitRoot() {
const root = workInProgressRoot;
commitWork(root);
workInProgressRoot = null;
root.onCommit?.(root.report);
}
function commitWork(fiber) {
for (let child = fiber.child; child; child = child.sibling) {
fiber.dom.appendChild(child.dom);
commitWork(child);
}
}- Lines 2 to 4: the root fiber being built, the next fiber to work on, and when the current slice of time ends.
- Lines 6 to 10: a
MessageChannelis the scheduler. Posting a message (line 13) asks the browser to callonmessageas a new task; between tasks, the browser can paint and handle clicks. Each task gets 5 ms (line 8). - Lines 16 to 18:
shouldYieldsays when those 5 ms are up. - Lines 20 to 29:
rendermakes the root fiber, which holds the container and the element, and asks for a task. TheonCommitcallback is only for this lesson's experiments; React has no such argument. - Lines 31 to 40, the work loop: count the slice (line 33), do fibers until time is up (lines 34 to 37), then either ask for another task (line 38) or, when no fiber is left, commit (line 39).
- Lines 42 to 49,
performUnitOfWork: work on one fiber (line 43), then return the next one: its child (line 44), or else its sibling or the nearest parent's sibling (lines 45 to 47), ornullwhen the tree is done (line 48). - Lines 51 to 60,
beginWork: make the fiber's DOM node, then a child fiber for each child element, linked bychildandsibling. - Lines 62 to 74, the commit: only when every fiber is done, add every node to its parent, in one go.
child, its next sibling, and its parent through return. The work loop needs only these three links to visit every fiber, one at a time, and to know where to go next.Step through the loop on a small list, h("ul", null, h("li", null, "Green"), h("li", null, "Black")):
next: rootrootulli Green"Green"li Black"Black"
if (fiber.child) return fiber.child;for (let next = fiber; next; next = next.return)if (next.sibling) return next.sibling;return null;
workInProgress is the root fiber. Press Step to run performUnitOfWork once.
The loop goes down while there are children, then across, then back up through return to find a sibling. Now try the same 20,000 teas, rendered by the loop:
// tiny-react.js, step 3 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };} // Step 2: DOM nodes.function createDom(fiber) { const dom = fiber.type === "TEXT" ? document.createTextNode("") : document.createElement(fiber.type); updateDom(dom, {}, fiber.props); return dom;} function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }} function eventName(prop) { return prop.slice(2).toLowerCase();} // Step 3: fibers, and a work loop that stops every 5 ms.let workInProgressRoot = null;let workInProgress = null;let deadline = 0; const channel = new MessageChannel();channel.port1.onmessage = () => { deadline = performance.now() + 5; workLoop();}; function requestWork() { channel.port2.postMessage(null);} function shouldYield() { return performance.now() >= deadline;} function render(element, container, onCommit) { startRender({ dom: container, props: { children: [element] }, onCommit });} function startRender({ dom, props, onCommit }) { const report = { slices: 0, fibers: 0, changes: [] }; workInProgressRoot = { dom, props, onCommit, report }; workInProgress = workInProgressRoot; requestWork();} function workLoop() { if (!workInProgress) return; workInProgressRoot.report.slices++; while (workInProgress && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); workInProgressRoot.report.fibers++; } if (workInProgress) requestWork(); else commitRoot();} function performUnitOfWork(fiber) { beginWork(fiber); if (fiber.child) return fiber.child; for (let next = fiber; next; next = next.return) { if (next.sibling) return next.sibling; } return null;} function beginWork(fiber) { fiber.dom ??= createDom(fiber); let previous = null; for (const element of fiber.props.children) { const child = { type: element.type, key: element.key, props: element.props, dom: null, return: fiber, child: null, sibling: null }; if (previous) previous.sibling = child; else fiber.child = child; previous = child; }} function commitRoot() { const root = workInProgressRoot; commitWork(root); workInProgressRoot = null; root.onCommit?.(root.report);} function commitWork(fiber) { for (let child = fiber.child; child; child = child.sibling) { fiber.dom.appendChild(child.dom); commitWork(child); }}Press Render 20,000 teas. The report says 40002 fibers: the root, the ul, and for each tea an li and its text. The number of slices depends on your computer. Between slices the browser can run the ticker, paint and handle clicks, so the page stays responsive while the list is built. The list appears all at once, at the commit.
React's loop is the same while, in ReactFiberWorkLoop.js, the file the work loop lesson read:
function workLoopConcurrentByScheduler() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-type] flow doesn't know that shouldYield() is side-effect free
performUnitOfWork(workInProgress);
}
}Lines 3 to 5 are our lines 34 to 37. React's performUnitOfWork sets workInProgress itself instead of returning the next fiber, and its shouldYield comes from the scheduler package, in Scheduler.js:
function shouldYieldToHost(): boolean {
if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
// Yield now.
return true;
}
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// Yield now.
return true;
}Lines 6 to 10: don't yield until frameInterval has passed since the slice started. frameInterval starts as frameYieldMs, which is 5 in SchedulerFeatureFlags.js: the same 5 ms. React's scheduler also starts its tasks with a MessageChannel, and keeps many tasks in a priority queue, where ours has one.
Step 4: reconciliation matches children by key
So far every render builds a new tree. To update the page instead of rebuilding it, the loop has to compare each new child with the fiber it had last time, and keep what it can. That's reconciliation. The tree on the screen is kept in currentRoot, and each new fiber points to its old self through alternate:
// Step 4: reconciliation, matched by key.
function reconcileChildren(fiber, elements) {
const oldByKey = new Map();
for (let old = fiber.alternate?.child; old; old = old.sibling) {
oldByKey.set(old.key ?? old.index, old);
}
const placeAll = !fiber.dom && fiber.flags === "Placement";
let lastPlacedIndex = 0;
let previous = null;
elements.forEach((element, index) => {
const id = element.key ?? index;
const old = oldByKey.get(id);
const same = old !== undefined && old.type === element.type;
if (same) oldByKey.delete(id);
const child = {
type: element.type,
key: element.key,
props: element.props,
index,
dom: same ? old.dom : null,
alternate: same ? old : null,
return: fiber,
child: null,
sibling: null,
flags: null,
};
if (!same || placeAll || old.index < lastPlacedIndex) child.flags = "Placement";
else lastPlacedIndex = old.index;
if (previous) previous.sibling = child;
else fiber.child = child;
previous = child;
});
fiber.deletions = [...oldByKey.values()];
}
function report(change, fiber) {
const name = fiber.type === "TEXT" ? JSON.stringify(fiber.props.nodeValue) : (fiber.type.name ?? fiber.type) + (fiber.key === null ? "" : " " + fiber.key);
workInProgressRoot.report.changes.push(change + " " + name);
}
function changedProps(fiber) {
const before = fiber.alternate.props;
return Object.keys({ ...before, ...fiber.props }).some((name) => name !== "children" && before[name] !== fiber.props[name]);
}- Lines 3 to 6: put the old children in a
Map, by key, or by position for children with no key. - Line 7: a component that is new or moved has no DOM node of its own, so its children must be placed too. Step 6 adds components.
- Lines 11 to 14: find the old fiber with this child's key. It is the same only if the type matches too; then it's taken out of the map.
- Lines 15 to 26: the new fiber. It keeps the old DOM node (line 20) and points to the old fiber (line 21) when it's the same.
- Lines 27 and 28 decide. A new fiber gets
Placement. An old one stays where it is if it was after every fiber kept so far; otherwise it getsPlacementtoo, which means "move".lastPlacedIndexis the old position of the last fiber that stayed. - Line 33: old fibers no one matched are kept in
deletions. - Lines 36 to 44 are helpers:
reportwrites a line for this lesson's log, andchangedPropssays whether any prop besideschildrenchanged.
Placement and move after it. That is the same rule as React's placeChild.In this step the commit only writes down the decisions; the next step carries them out. So after the first render the list on the page doesn't change. Press Mint first, Add Chai and Remove Green, and read the log:
// tiny-react.js, step 4 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };} // Step 2: DOM nodes.function createDom(fiber) { const dom = fiber.type === "TEXT" ? document.createTextNode("") : document.createElement(fiber.type); updateDom(dom, {}, fiber.props); return dom;} function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }} function eventName(prop) { return prop.slice(2).toLowerCase();} // Step 3: fibers, and a work loop that stops every 5 ms.let currentRoot = null;let workInProgressRoot = null;let workInProgress = null;let deadline = 0; const channel = new MessageChannel();channel.port1.onmessage = () => { deadline = performance.now() + 5; workLoop();}; function requestWork() { channel.port2.postMessage(null);} function shouldYield() { return performance.now() >= deadline;} function render(element, container, onCommit) { startRender({ dom: container, props: { children: [element] }, onCommit });} function startRender({ dom, props, onCommit }) { const report = { slices: 0, fibers: 0, changes: [] }; workInProgressRoot = { dom, props, onCommit, report, alternate: currentRoot, child: null, deletions: [] }; workInProgress = workInProgressRoot; requestWork();} function workLoop() { if (!workInProgress) return; workInProgressRoot.report.slices++; while (workInProgress && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); workInProgressRoot.report.fibers++; } if (workInProgress) requestWork(); else commitRoot();} function performUnitOfWork(fiber) { beginWork(fiber); if (fiber.child) return fiber.child; for (let next = fiber; next; next = next.return) { if (next.sibling) return next.sibling; } return null;} function beginWork(fiber) { fiber.dom ??= createDom(fiber); reconcileChildren(fiber, fiber.props.children);} // Step 4: reconciliation, matched by key.function reconcileChildren(fiber, elements) { const oldByKey = new Map(); for (let old = fiber.alternate?.child; old; old = old.sibling) { oldByKey.set(old.key ?? old.index, old); } const placeAll = !fiber.dom && fiber.flags === "Placement"; let lastPlacedIndex = 0; let previous = null; elements.forEach((element, index) => { const id = element.key ?? index; const old = oldByKey.get(id); const same = old !== undefined && old.type === element.type; if (same) oldByKey.delete(id); const child = { type: element.type, key: element.key, props: element.props, index, dom: same ? old.dom : null, alternate: same ? old : null, return: fiber, child: null, sibling: null, flags: null, }; if (!same || placeAll || old.index < lastPlacedIndex) child.flags = "Placement"; else lastPlacedIndex = old.index; if (previous) previous.sibling = child; else fiber.child = child; previous = child; }); fiber.deletions = [...oldByKey.values()];} function report(change, fiber) { const name = fiber.type === "TEXT" ? JSON.stringify(fiber.props.nodeValue) : (fiber.type.name ?? fiber.type) + (fiber.key === null ? "" : " " + fiber.key); workInProgressRoot.report.changes.push(change + " " + name);} function changedProps(fiber) { const before = fiber.alternate.props; return Object.keys({ ...before, ...fiber.props }).some((name) => name !== "children" && before[name] !== fiber.props[name]);} // Step 4 only: write down what reconciliation decided. Step 5 changes the page.function commitRoot() { const root = workInProgressRoot; if (currentRoot) describeChanges(root); else commitFirstRender(root); currentRoot = root; workInProgressRoot = null; root.onCommit?.(root.report);} function commitFirstRender(fiber) { for (let child = fiber.child; child; child = child.sibling) { fiber.dom.appendChild(child.dom); commitFirstRender(child); }} function describeChanges(fiber) { for (const old of fiber.deletions) report("delete", old); for (let child = fiber.child; child; child = child.sibling) { if (child.flags === "Placement") report(child.alternate ? "move" : "insert", child); else if (changedProps(child)) report("update", child); describeChanges(child); child.alternate = null; }}Mint first logs move li Green and move li Black: Mint stays, and the two before it move, as in the picture. Add Chai logs insert li Chai and insert "Chai", its text. Remove Green logs delete li Green. The text inside each li never changes, so it's never logged.
Our lastPlacedIndex rule is React's, in placeChild in ReactChildFiber.js:
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// During hydration, the useId algorithm needs to know which fibers are
// part of a list of children (arrays, iterators).
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move. The fiber already existed, so this is not a new
// mount; don't set PlacementDEV, which would cause StrictMode to
// re-run the effects in its subtree as if it had remounted.
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// This is an insertion.
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}- Line 13:
currentis the old fiber, ourold. - Lines 15 to 21: if its old index is less than
lastPlacedIndex, it's a move:flags |= Placement. - Lines 22 to 24: otherwise it stays, and its old index becomes the new
lastPlacedIndex. - Lines 26 to 29: no old fiber means an insertion. Lines 7 to 11 skip all this when React isn't tracking changes, such as on a first render.
React keeps flags as bits in a number, so one fiber can carry several at once, and it first walks both lists side by side while the keys match before it builds a map. The idea is the same.
Step 5: commit applies every change at once
Reconciliation only decides. The commit makes the changes, and it runs only after the work loop has finished the whole tree, in one go. If the loop changed the DOM as it went, a paused render would leave the screen showing half of an update; the commit phase lesson saw why React never does that.
// Step 5: commit, all at once.
function commitRoot() {
const root = workInProgressRoot;
commitWork(root);
currentRoot = root;
workInProgressRoot = null;
root.onCommit?.(root.report);
}
function commitWork(fiber) {
for (const old of fiber.deletions) commitDeletion(old);
if (fiber.dom && fiber.return) {
if (fiber.flags === "Placement") {
hostParent(fiber).insertBefore(fiber.dom, nodeAfter(fiber));
report(fiber.alternate ? "move" : "insert", fiber);
}
if (fiber.alternate && changedProps(fiber)) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
report("update", fiber);
}
}
for (let child = fiber.child; child; child = child.sibling) commitWork(child);
fiber.flags = null;
fiber.alternate = null;
}- Lines 2 to 8: commit the tree, then make it the tree on the screen (line 5), so the next render compares against it.
- Line 11: first remove the fibers reconciliation didn't match.
- Lines 13 to 16: a fiber with
Placementis inserted before the next node that's already in place. For a fiber that already had a DOM node,insertBeforemoves it. - Lines 17 to 20: a kept fiber whose props changed gets
updateDomwith its old and new props. - Line 22 commits the children, in order. Lines 23 and 24 clear the flags and drop the old fiber, which is no longer needed.
Where a node goes is worked out by three small helpers:
function hostParent(fiber) {
let parent = fiber.return;
while (!parent.dom) parent = parent.return;
return parent.dom;
}
function nodeAfter(fiber) {
for (let sibling = fiber.sibling; sibling; sibling = sibling.sibling) {
const node = firstPlacedNode(sibling);
if (node) return node;
}
return fiber.return.dom ? null : nodeAfter(fiber.return);
}
function firstPlacedNode(fiber) {
if (fiber.flags === "Placement") return null;
if (fiber.dom) return fiber.dom;
for (let child = fiber.child; child; child = child.sibling) {
const node = firstPlacedNode(child);
if (node) return node;
}
return null;
}hostParent (lines 1 to 5) climbs to the nearest fiber with a DOM node: components have none. nodeAfter (lines 7 to 13) looks along the following siblings for the first node that is already on the page, and, inside a component, carries on after the component. firstPlacedNode (lines 15 to 23) skips fibers that are still waiting to be placed (line 16). React does the same search in getHostSibling. Now the list really changes. Type a note beside Green, then press Mint first:
// tiny-react.js, step 5 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };} // Step 2: DOM nodes.function createDom(fiber) { const dom = fiber.type === "TEXT" ? document.createTextNode("") : document.createElement(fiber.type); updateDom(dom, {}, fiber.props); return dom;} function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }} function eventName(prop) { return prop.slice(2).toLowerCase();} // Step 3: fibers, and a work loop that stops every 5 ms.let currentRoot = null;let workInProgressRoot = null;let workInProgress = null;let deadline = 0; const channel = new MessageChannel();channel.port1.onmessage = () => { deadline = performance.now() + 5; workLoop();}; function requestWork() { channel.port2.postMessage(null);} function shouldYield() { return performance.now() >= deadline;} function render(element, container, onCommit) { startRender({ dom: container, props: { children: [element] }, onCommit });} function startRender({ dom, props, onCommit }) { const report = { slices: 0, fibers: 0, changes: [] }; workInProgressRoot = { dom, props, onCommit, report, alternate: currentRoot, child: null, deletions: [] }; workInProgress = workInProgressRoot; requestWork();} function workLoop() { if (!workInProgress) return; workInProgressRoot.report.slices++; while (workInProgress && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); workInProgressRoot.report.fibers++; } if (workInProgress) requestWork(); else commitRoot();} function performUnitOfWork(fiber) { beginWork(fiber); if (fiber.child) return fiber.child; for (let next = fiber; next; next = next.return) { if (next.sibling) return next.sibling; } return null;} function beginWork(fiber) { fiber.dom ??= createDom(fiber); reconcileChildren(fiber, fiber.props.children);} // Step 4: reconciliation, matched by key.function reconcileChildren(fiber, elements) { const oldByKey = new Map(); for (let old = fiber.alternate?.child; old; old = old.sibling) { oldByKey.set(old.key ?? old.index, old); } const placeAll = !fiber.dom && fiber.flags === "Placement"; let lastPlacedIndex = 0; let previous = null; elements.forEach((element, index) => { const id = element.key ?? index; const old = oldByKey.get(id); const same = old !== undefined && old.type === element.type; if (same) oldByKey.delete(id); const child = { type: element.type, key: element.key, props: element.props, index, dom: same ? old.dom : null, alternate: same ? old : null, return: fiber, child: null, sibling: null, flags: null, }; if (!same || placeAll || old.index < lastPlacedIndex) child.flags = "Placement"; else lastPlacedIndex = old.index; if (previous) previous.sibling = child; else fiber.child = child; previous = child; }); fiber.deletions = [...oldByKey.values()];} function report(change, fiber) { const name = fiber.type === "TEXT" ? JSON.stringify(fiber.props.nodeValue) : (fiber.type.name ?? fiber.type) + (fiber.key === null ? "" : " " + fiber.key); workInProgressRoot.report.changes.push(change + " " + name);} function changedProps(fiber) { const before = fiber.alternate.props; return Object.keys({ ...before, ...fiber.props }).some((name) => name !== "children" && before[name] !== fiber.props[name]);} // Step 5: commit, all at once.function commitRoot() { const root = workInProgressRoot; commitWork(root); currentRoot = root; workInProgressRoot = null; root.onCommit?.(root.report);} function commitWork(fiber) { for (const old of fiber.deletions) commitDeletion(old); if (fiber.dom && fiber.return) { if (fiber.flags === "Placement") { hostParent(fiber).insertBefore(fiber.dom, nodeAfter(fiber)); report(fiber.alternate ? "move" : "insert", fiber); } if (fiber.alternate && changedProps(fiber)) { updateDom(fiber.dom, fiber.alternate.props, fiber.props); report("update", fiber); } } for (let child = fiber.child; child; child = child.sibling) commitWork(child); fiber.flags = null; fiber.alternate = null;} function hostParent(fiber) { let parent = fiber.return; while (!parent.dom) parent = parent.return; return parent.dom;} function nodeAfter(fiber) { for (let sibling = fiber.sibling; sibling; sibling = sibling.sibling) { const node = firstPlacedNode(sibling); if (node) return node; } return fiber.return.dom ? null : nodeAfter(fiber.return);} function firstPlacedNode(fiber) { if (fiber.flags === "Placement") return null; if (fiber.dom) return fiber.dom; for (let child = fiber.child; child; child = child.sibling) { const node = firstPlacedNode(child); if (node) return node; } return null;} function commitDeletion(fiber) { for (const node of topNodes(fiber)) node.remove(); report("delete", fiber);} function topNodes(fiber) { if (fiber.dom) return [fiber.dom]; const nodes = []; for (let child = fiber.child; child; child = child.sibling) nodes.push(...topNodes(child)); return nodes;}Your note stays beside Green: the commit moved Green's li, with its input, after Mint. Nothing was made again, so the browser kept what you typed. The log shows the same two moves as step 4. Exercise 4 removes the keys and shows what goes wrong.
React moves a node with insertBefore in ReactFiberConfigDOM.js:
export function insertBefore(
parentInstance: Instance,
child: Instance | TextInstance,
beforeChild: Instance | TextInstance | SuspenseInstance | ActivityInstance,
): void {
if (supportsMoveBefore && child.parentNode !== null) {
// $FlowFixMe[prop-missing]: We've checked this with supportsMoveBefore.
parentInstance.moveBefore(child, beforeChild);
} else {
parentInstance.insertBefore(child, beforeChild);
}
}Line 10 is our line 14. Lines 6 to 8 would use the browser's newer moveBefore, which moves a node without resetting it, but its flag, enableMoveBefore, is off in React 19.3.1, so line 10 is what runs. Then React swaps the trees, at the end of the mutation phase, in ReactFiberWorkLoop.js:
// The work-in-progress tree is now the current tree. This must come after
// the mutation phase, so that the previous tree is still current during
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
root.current = finishedWork;Line 5, root.current = finishedWork, is our currentRoot = root. The comment says why it's placed there: after the DOM changes, before layout effects.
Step 6: useState and its update queue
Components are functions that return elements. A fiber whose type is a function has no DOM node; to get its children, the loop calls it. While it runs, its hooks need somewhere to keep their state between renders: the fiber. As the hooks internals lesson showed, React finds each hook's state again by the order of the calls.
function beginWork(fiber) {
if (typeof fiber.type === "function") {
renderingFiber = fiber;
hookIndex = 0;
fiber.hooks = [];
reconcileChildren(fiber, toChildren(fiber.type(fiber.props)));
} else {
fiber.dom ??= createDom(fiber);
reconcileChildren(fiber, fiber.props.children);
}
}
// Step 6: useState, with an update queue.
let renderingFiber = null;
let hookIndex = 0;
function useState(initialState) {
const old = renderingFiber.alternate?.hooks[hookIndex];
const queue = old ? old.queue : { pending: [], dispatch: null };
let state = old ? old.state : typeof initialState === "function" ? initialState() : initialState;
for (const action of queue.pending) {
state = typeof action === "function" ? action(state) : action;
}
queue.dispatch ??= (action) => {
queue.pending.push(action);
startRender(currentRoot ?? workInProgressRoot);
};
renderingFiber.hooks[hookIndex++] = { state, queue, applied: queue.pending.length };
return [state, queue.dispatch];
}- Lines 2 to 6: for a component, remember which fiber is rendering (line 3), start counting hooks at 0 (line 4), give the fiber an empty
hooksarray (line 5), and call the component to get its children (line 6). - Line 18: find this hook in the old fiber, by position.
- Line 19: the queue is shared between renders: it holds the updates waiting to be applied, and the
dispatchfunction. - Lines 20 to 23: start from the old state (or the initial one on the first render), then apply each waiting update in order. An update can be a value or a function of the state.
- Lines 24 to 27:
dispatch, yoursetState, adds the update to the queue and starts a new render from the root. It's made once, so it's the same function on every render. - Line 28: keep the hook in the next place, with how many updates it applied. The commit removes those from the queue.
useState call takes the next place in the fiber's hooks array, so the order of calls is how it finds its state again. Order 3 puts three updates in the queue; the next render applies all three, once.// tiny-react.js, step 6 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };} // Step 2: DOM nodes.function createDom(fiber) { const dom = fiber.type === "TEXT" ? document.createTextNode("") : document.createElement(fiber.type); updateDom(dom, {}, fiber.props); return dom;} function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }} function eventName(prop) { return prop.slice(2).toLowerCase();} // Step 3: fibers, and a work loop that stops every 5 ms.let currentRoot = null;let workInProgressRoot = null;let workInProgress = null;let deadline = 0; const channel = new MessageChannel();channel.port1.onmessage = () => { deadline = performance.now() + 5; workLoop();}; function requestWork() { channel.port2.postMessage(null);} function shouldYield() { return performance.now() >= deadline;} function render(element, container, onCommit) { startRender({ dom: container, props: { children: [element] }, onCommit });} function startRender({ dom, props, onCommit }) { const report = { slices: 0, fibers: 0, changes: [] }; workInProgressRoot = { dom, props, onCommit, report, alternate: currentRoot, child: null, deletions: [] }; workInProgress = workInProgressRoot; requestWork();} function workLoop() { if (!workInProgress) return; workInProgressRoot.report.slices++; while (workInProgress && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); workInProgressRoot.report.fibers++; } if (workInProgress) requestWork(); else commitRoot();} function performUnitOfWork(fiber) { beginWork(fiber); if (fiber.child) return fiber.child; for (let next = fiber; next; next = next.return) { if (next.sibling) return next.sibling; } return null;} function beginWork(fiber) { if (typeof fiber.type === "function") { renderingFiber = fiber; hookIndex = 0; fiber.hooks = []; reconcileChildren(fiber, toChildren(fiber.type(fiber.props))); } else { fiber.dom ??= createDom(fiber); reconcileChildren(fiber, fiber.props.children); }} // Step 4: reconciliation, matched by key.function reconcileChildren(fiber, elements) { const oldByKey = new Map(); for (let old = fiber.alternate?.child; old; old = old.sibling) { oldByKey.set(old.key ?? old.index, old); } const placeAll = !fiber.dom && fiber.flags === "Placement"; let lastPlacedIndex = 0; let previous = null; elements.forEach((element, index) => { const id = element.key ?? index; const old = oldByKey.get(id); const same = old !== undefined && old.type === element.type; if (same) oldByKey.delete(id); const child = { type: element.type, key: element.key, props: element.props, index, dom: same ? old.dom : null, alternate: same ? old : null, return: fiber, child: null, sibling: null, flags: null, }; if (!same || placeAll || old.index < lastPlacedIndex) child.flags = "Placement"; else lastPlacedIndex = old.index; if (previous) previous.sibling = child; else fiber.child = child; previous = child; }); fiber.deletions = [...oldByKey.values()];} function report(change, fiber) { const name = fiber.type === "TEXT" ? JSON.stringify(fiber.props.nodeValue) : (fiber.type.name ?? fiber.type) + (fiber.key === null ? "" : " " + fiber.key); workInProgressRoot.report.changes.push(change + " " + name);} function changedProps(fiber) { const before = fiber.alternate.props; return Object.keys({ ...before, ...fiber.props }).some((name) => name !== "children" && before[name] !== fiber.props[name]);} // Step 5: commit, all at once.function commitRoot() { const root = workInProgressRoot; commitWork(root); currentRoot = root; workInProgressRoot = null; root.onCommit?.(root.report);} function commitWork(fiber) { for (const old of fiber.deletions) commitDeletion(old); if (fiber.dom && fiber.return) { if (fiber.flags === "Placement") { hostParent(fiber).insertBefore(fiber.dom, nodeAfter(fiber)); report(fiber.alternate ? "move" : "insert", fiber); } if (fiber.alternate && changedProps(fiber)) { updateDom(fiber.dom, fiber.alternate.props, fiber.props); report("update", fiber); } } for (let child = fiber.child; child; child = child.sibling) commitWork(child); if (fiber.hooks) { for (const hook of fiber.hooks) if (hook.queue) hook.queue.pending.splice(0, hook.applied); } fiber.flags = null; fiber.alternate = null;} function hostParent(fiber) { let parent = fiber.return; while (!parent.dom) parent = parent.return; return parent.dom;} function nodeAfter(fiber) { for (let sibling = fiber.sibling; sibling; sibling = sibling.sibling) { const node = firstPlacedNode(sibling); if (node) return node; } return fiber.return.dom ? null : nodeAfter(fiber.return);} function firstPlacedNode(fiber) { if (fiber.flags === "Placement") return null; if (fiber.dom) return fiber.dom; for (let child = fiber.child; child; child = child.sibling) { const node = firstPlacedNode(child); if (node) return node; } return null;} function commitDeletion(fiber) { for (const node of topNodes(fiber)) node.remove(); report("delete", fiber);} function topNodes(fiber) { if (fiber.dom) return [fiber.dom]; const nodes = []; for (let child = fiber.child; child; child = child.sibling) nodes.push(...topNodes(child)); return nodes;} // Step 6: useState, with an update queue.let renderingFiber = null;let hookIndex = 0; function useState(initialState) { const old = renderingFiber.alternate?.hooks[hookIndex]; const queue = old ? old.queue : { pending: [], dispatch: null }; let state = old ? old.state : typeof initialState === "function" ? initialState() : initialState; for (const action of queue.pending) { state = typeof action === "function" ? action(state) : action; } queue.dispatch ??= (action) => { queue.pending.push(action); startRender(currentRoot ?? workInProgressRoot); }; renderingFiber.hooks[hookIndex++] = { state, queue, applied: queue.pending.length }; return [state, queue.dispatch];}Press Order 3. Lines 14 to 16 of app.js call setOrders three times, and the page shows Orders: 3 and Shop has rendered 2 times: one render for three updates. Each dispatch starts the render over, but nothing runs until the next task, and by then all three updates are in the queue. That's batching. Press a +1, then Reverse: each row keeps its cups, because the rows are matched by key, and the state lives on the fiber that moved.
Notice that a +1 renders Shop again too: tiny React renders the whole tree on every update. React skips components whose props, state and context didn't change. React's first render of useState, in ReactFiberHooks.js, makes the same two things:
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState as any,
};
hook.queue = queue;
return hook;Line 1 stores the state on the hook, and lines 2 to 8 make its queue, with pending updates and a dispatch. React keeps hooks in a linked list instead of an array, gives each update a lane, and keeps pending as a ring of update objects, but the shape is ours.
Step 7: useEffect runs after the commit
Effects sync a component with something outside React, such as the page title or a timer. They must run after the DOM has changed, and only when their dependencies changed. Our useEffect only writes down what to run; the commit collects the fibers; a later task runs them:
// Step 7: useEffect, after the commit.
let pendingEffects = [];
let pendingUnmounts = [];
function useEffect(effect, deps) {
const old = renderingFiber.alternate?.hooks[hookIndex];
const changed = !old || deps === undefined || deps.some((dep, i) => !Object.is(dep, old.deps[i]));
renderingFiber.hooks[hookIndex++] = { deps, effect: changed ? effect : null, cleanup: old?.cleanup };
}
function collectUnmounts(fiber) {
for (const hook of fiber.hooks ?? []) if (hook.cleanup) pendingUnmounts.push(hook.cleanup);
for (let child = fiber.child; child; child = child.sibling) collectUnmounts(child);
}
function flushEffects() {
const unmounts = pendingUnmounts;
const fibers = pendingEffects;
pendingUnmounts = [];
pendingEffects = [];
for (const cleanup of unmounts) cleanup();
for (const fiber of fibers) {
for (const hook of fiber.hooks) if (hook.effect && hook.cleanup) hook.cleanup();
}
for (const fiber of fibers) {
for (const hook of fiber.hooks) {
if (!hook.effect) continue;
hook.cleanup = hook.effect();
hook.effect = null;
}
}
}- Lines 5 to 9: compare each dependency with last time's, using
Object.is. With no array, the effect always runs. The hook keeps the effect only if it should run, and always keeps the old cleanup. - Lines 11 to 14: when a fiber is deleted, collect the cleanups of every hook below it.
- Lines 16 to 32,
flushEffects: first the cleanups of removed components (line 21), then the cleanups of effects about to run again (lines 22 to 24), then the effects, keeping what each returns as its next cleanup (lines 25 to 31).
Three small changes join it to the rest. The commit adds every fiber with hooks to pendingEffects, after its children, and a deleted fiber calls collectUnmounts. commitRoot asks for a new task when there are effects. And the task calls flushEffects() before it starts any new render. So effects run after the browser has had a chance to paint, as React's usually do (React runs them sooner after a click, as the event system lesson showed). Press the buttons and read the log:
// tiny-react.js, step 7 of 7: a small React written for this lesson.// Real React does much more; the lesson's last section says what. // Step 1: elements describe the page.function createElement(type, config, ...children) { const { key = null, ...props } = config ?? {}; props.children = toChildren(children); return { type, key: key === null ? null : String(key), props };} function toChildren(values) { return [values] .flat(Infinity) .filter((value) => value !== null && value !== undefined && typeof value !== "boolean") .map((value) => (typeof value === "object" ? value : createTextElement(value)));} function createTextElement(text) { return { type: "TEXT", key: null, props: { nodeValue: String(text), children: [] } };} // Step 2: DOM nodes.function createDom(fiber) { const dom = fiber.type === "TEXT" ? document.createTextNode("") : document.createElement(fiber.type); updateDom(dom, {}, fiber.props); return dom;} function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }} function eventName(prop) { return prop.slice(2).toLowerCase();} // Step 3: fibers, and a work loop that stops every 5 ms.let currentRoot = null;let workInProgressRoot = null;let workInProgress = null;let deadline = 0; const channel = new MessageChannel();channel.port1.onmessage = () => { deadline = performance.now() + 5; flushEffects(); workLoop();}; function requestWork() { channel.port2.postMessage(null);} function shouldYield() { return performance.now() >= deadline;} function render(element, container, onCommit) { startRender({ dom: container, props: { children: [element] }, onCommit });} function startRender({ dom, props, onCommit }) { const report = { slices: 0, fibers: 0, changes: [] }; workInProgressRoot = { dom, props, onCommit, report, alternate: currentRoot, child: null, deletions: [] }; workInProgress = workInProgressRoot; requestWork();} function workLoop() { if (!workInProgress) return; workInProgressRoot.report.slices++; while (workInProgress && !shouldYield()) { workInProgress = performUnitOfWork(workInProgress); workInProgressRoot.report.fibers++; } if (workInProgress) requestWork(); else commitRoot();} function performUnitOfWork(fiber) { beginWork(fiber); if (fiber.child) return fiber.child; for (let next = fiber; next; next = next.return) { if (next.sibling) return next.sibling; } return null;} function beginWork(fiber) { if (typeof fiber.type === "function") { renderingFiber = fiber; hookIndex = 0; fiber.hooks = []; reconcileChildren(fiber, toChildren(fiber.type(fiber.props))); } else { fiber.dom ??= createDom(fiber); reconcileChildren(fiber, fiber.props.children); }} // Step 4: reconciliation, matched by key.function reconcileChildren(fiber, elements) { const oldByKey = new Map(); for (let old = fiber.alternate?.child; old; old = old.sibling) { oldByKey.set(old.key ?? old.index, old); } const placeAll = !fiber.dom && fiber.flags === "Placement"; let lastPlacedIndex = 0; let previous = null; elements.forEach((element, index) => { const id = element.key ?? index; const old = oldByKey.get(id); const same = old !== undefined && old.type === element.type; if (same) oldByKey.delete(id); const child = { type: element.type, key: element.key, props: element.props, index, dom: same ? old.dom : null, alternate: same ? old : null, return: fiber, child: null, sibling: null, flags: null, }; if (!same || placeAll || old.index < lastPlacedIndex) child.flags = "Placement"; else lastPlacedIndex = old.index; if (previous) previous.sibling = child; else fiber.child = child; previous = child; }); fiber.deletions = [...oldByKey.values()];} function report(change, fiber) { const name = fiber.type === "TEXT" ? JSON.stringify(fiber.props.nodeValue) : (fiber.type.name ?? fiber.type) + (fiber.key === null ? "" : " " + fiber.key); workInProgressRoot.report.changes.push(change + " " + name);} function changedProps(fiber) { const before = fiber.alternate.props; return Object.keys({ ...before, ...fiber.props }).some((name) => name !== "children" && before[name] !== fiber.props[name]);} // Step 5: commit, all at once.function commitRoot() { const root = workInProgressRoot; commitWork(root); currentRoot = root; workInProgressRoot = null; if (pendingEffects.length || pendingUnmounts.length) requestWork(); root.onCommit?.(root.report);} function commitWork(fiber) { for (const old of fiber.deletions) commitDeletion(old); if (fiber.dom && fiber.return) { if (fiber.flags === "Placement") { hostParent(fiber).insertBefore(fiber.dom, nodeAfter(fiber)); report(fiber.alternate ? "move" : "insert", fiber); } if (fiber.alternate && changedProps(fiber)) { updateDom(fiber.dom, fiber.alternate.props, fiber.props); report("update", fiber); } } for (let child = fiber.child; child; child = child.sibling) commitWork(child); if (fiber.hooks) { for (const hook of fiber.hooks) if (hook.queue) hook.queue.pending.splice(0, hook.applied); pendingEffects.push(fiber); } fiber.flags = null; fiber.alternate = null;} function hostParent(fiber) { let parent = fiber.return; while (!parent.dom) parent = parent.return; return parent.dom;} function nodeAfter(fiber) { for (let sibling = fiber.sibling; sibling; sibling = sibling.sibling) { const node = firstPlacedNode(sibling); if (node) return node; } return fiber.return.dom ? null : nodeAfter(fiber.return);} function firstPlacedNode(fiber) { if (fiber.flags === "Placement") return null; if (fiber.dom) return fiber.dom; for (let child = fiber.child; child; child = child.sibling) { const node = firstPlacedNode(child); if (node) return node; } return null;} function commitDeletion(fiber) { for (const node of topNodes(fiber)) node.remove(); report("delete", fiber); collectUnmounts(fiber);} function topNodes(fiber) { if (fiber.dom) return [fiber.dom]; const nodes = []; for (let child = fiber.child; child; child = child.sibling) nodes.push(...topNodes(child)); return nodes;} // Step 6: useState, with an update queue.let renderingFiber = null;let hookIndex = 0; function useState(initialState) { const old = renderingFiber.alternate?.hooks[hookIndex]; const queue = old ? old.queue : { pending: [], dispatch: null }; let state = old ? old.state : typeof initialState === "function" ? initialState() : initialState; for (const action of queue.pending) { state = typeof action === "function" ? action(state) : action; } queue.dispatch ??= (action) => { queue.pending.push(action); startRender(currentRoot ?? workInProgressRoot); }; renderingFiber.hooks[hookIndex++] = { state, queue, applied: queue.pending.length }; return [state, queue.dispatch];} // Step 7: useEffect, after the commit.let pendingEffects = [];let pendingUnmounts = []; function useEffect(effect, deps) { const old = renderingFiber.alternate?.hooks[hookIndex]; const changed = !old || deps === undefined || deps.some((dep, i) => !Object.is(dep, old.deps[i])); renderingFiber.hooks[hookIndex++] = { deps, effect: changed ? effect : null, cleanup: old?.cleanup };} function collectUnmounts(fiber) { for (const hook of fiber.hooks ?? []) if (hook.cleanup) pendingUnmounts.push(hook.cleanup); for (let child = fiber.child; child; child = child.sibling) collectUnmounts(child);} function flushEffects() { const unmounts = pendingUnmounts; const fibers = pendingEffects; pendingUnmounts = []; pendingEffects = []; for (const cleanup of unmounts) cleanup(); for (const fiber of fibers) { for (const hook of fiber.hooks) if (hook.effect && hook.cleanup) hook.cleanup(); } for (const fiber of fibers) { for (const hook of fiber.hooks) { if (!hook.effect) continue; hook.cleanup = hook.effect(); hook.effect = null; } }}The first line, effect: the title is Cart (0), is Cart's effect after the first commit. Add to cart changes items, so the effect runs again. Start steeping renders Timer: its effect starts a timer, and Cart's effect doesn't run, because items didn't change. Stop steeping deletes Timer, and its cleanup stops the timer.
React compares dependencies the same way, in areHookInputsEqual in ReactFiberHooks.js:
// $FlowFixMe[incompatible-use] found when upgrading Flow
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
// $FlowFixMe[incompatible-use] found when upgrading Flow
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}Line 4, is(nextDeps[i], prevDeps[i]), is Object.is: one changed dependency (line 7) and the effect runs. In development, StrictMode also runs every effect and cleanup one extra time on mount, which ours doesn't.
What real React does that ours doesn't
Tiny React has the shape of React, but every part of it is simpler. Each row is one thing React does that ours doesn't, and the lesson that shows it:
| The job | What tiny React does | What React does |
|---|---|---|
| Choosing what to render first | One render at a time, in the order updates arrive. | Gives each update a lane, renders urgent lanes first, and lets a transition wait. |
| Scheduling the work | One task at a time, with a 5 ms budget. | A scheduler with a priority queue of tasks, each with a deadline. |
| Interrupting a render | A new update throws the unfinished render away and starts over. | Throws it away only for a more urgent update. Also suspends on data, shows fallbacks, and keeps the old screen during a transition. |
| Skipping work | Renders the whole tree on every update. | Skips components whose props, state and context didn't change, using lanes on each fiber. |
| Reusing fibers | Makes new fiber objects on every render. | Keeps two trees, current and work in progress, and reuses each fiber's alternate. |
| Events | Adds a listener to each element. | Listens once per event type at the root, and makes synthetic events with priorities. |
| Hooks | An array of hooks and an array of updates. | A linked list of hooks, and a ring of update objects, each with a lane. |
| The commit | Inserts, moves, updates and deletes nodes. | Also attaches refs, runs layout effects before paint, and inserts a new subtree in one piece. |
| Server HTML | Always makes new DOM nodes. | Hydrates existing HTML from the server, and renders Server Components. |
| Memoization | None. | memo, useMemo and the React Compiler's cache skip repeated work. |
Each row has a lesson that reads React's own code for it:
- Priorities: Lanes, and The scheduler package.
- Interrupting and Suspense: Concurrent rendering, Suspense and transitions inside.
- Skipping work: The work loop (bailouts), and Fiber (two trees, current and work in progress).
- Events: React's event system.
- Hooks and updates: How hooks work inside, and What setState really does.
- The commit: The commit phase (refs, layout effects).
- Server HTML and the server: Hydration inside, and Server Components inside.
- Memoization: How the React Compiler works.
- Matches children by key
- Renders in slices of about 5 ms
- Changes the DOM only after the whole tree is ready
- Renders once for three setState calls in one click
- Gives a click's update a higher priority than a timer's
- Skips a component whose props and state didn't change
- Listens for clicks once, at the root
- Reuses HTML that the server already sent
Put each feature under the React that has it.
The same shop on tiny React and on React
Because tiny React has the same API, the shop from the start of the lesson runs on React too. This is the same app.js, with two changes: line 1 imports createElement, useState and useEffect from React, and the end exports App instead of calling our render. Type in both, and press the same buttons:
import { createElement as h, useEffect, useState } from "react"; const teas = ["Green", "Black", "Mint", "Chai", "Jasmine"]; function TeaRow({ tea }) { const [cups, setCups] = useState(0); return h("li", null, tea + ": " + cups + " ", h("button", { onClick: () => setCups(cups + 1) }, "+1"));} function Shop() { const [query, setQuery] = useState(""); const shown = teas.filter((tea) => tea.toLowerCase().includes(query.toLowerCase())); useEffect(() => { document.title = shown.length + " teas"; }, [shown.length]); return h( "section", null, h("input", { value: query, onInput: (event) => setQuery(event.target.value), placeholder: "Search teas" }), h("p", null, shown.length + " of " + teas.length + " teas"), h("ul", null, shown.map((tea) => h(TeaRow, { key: tea, tea }))), );} export default function App() { return h(Shop);}Both show 5 of 5 teas, filter as you type, keep each row's count while you filter, and set the page title to the number of teas shown. The differences are inside. React listens for input once at the root and gives an update from typing a high priority. A +1 renders only that row's TeaRow, where tiny React renders the whole tree. And in development React runs each component twice to catch impure code. You now know where each of those lives.
Common mix-ups
- "The work loop changes the DOM as it goes." No. The loop only builds fibers and decides; the commit changes the DOM, all at once, after the loop is done.
- "Time slicing makes rendering faster." No. The same work is done, in pieces. The page stays responsive because the browser gets a turn between pieces.
- "A key just stops a warning." No. The key is how reconciliation finds a child's old fiber, with its DOM node and its state.
- "setState changes the state right away." No. It adds an update to a queue and asks for a render; the state changes when that render applies the queue.
- "Effects run during the render." No. They run in a later task, after the commit.
- "React is this, plus a few features." No. It's the same shape, but every part is far more careful: priorities, bailouts, events, errors, Suspense, hydration and more.
Practice exercises
With step 1's createElement, how many element objects does this line make, counting the p?
const price = createElement("p", null, "Price: ", 4);{ type: "p", key: null, props: { children: [
{ type: "TEXT", key: null, props: { nodeValue: "Price: ", children: [] } },
{ type: "TEXT", key: null, props: { nodeValue: "4", children: [] } },
] } }Three element objects: the p, and one TEXT element for each child. Each has its own props object, but those are part of their elements.
In what order does step 3's work loop visit the fibers for this tree? Name each fiber by its type, and the texts by their words.
h("ul", null, h("li", null, "Green"), h("li", null, "Black"))root, ul, li, Green, li, BlackperformUnitOfWork returns the child first, so the loop goes root, ul, the first li, its text. The text has no child or sibling, so it climbs to the first li, whose sibling is the second li; then its text. That's the order the playground shows.
The update-dom.js tab replaces the library's updateDom, and app.js passes style: { color: "seagreen", fontWeight: "bold" }. The text isn't green yet. Make style objects work.
// This replaces the library's updateDom. Make style objects work.function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else dom[name] = value; }}// This replaces the library's updateDom. Style objects are set one property at a time.function updateDom(dom, prevProps, nextProps) { for (const [name, value] of Object.entries(prevProps)) { if (name === "children" || nextProps[name] === value) continue; if (name.startsWith("on")) dom.removeEventListener(eventName(name), value); else if (name === "style") dom.style.cssText = ""; else if (!(name in nextProps)) dom[name] = ""; } for (const [name, value] of Object.entries(nextProps)) { if (name === "children" || prevProps[name] === value) continue; if (name.startsWith("on")) dom.addEventListener(eventName(name), value); else if (name === "style") Object.assign(dom.style, value); else dom[name] = value; }}A DOM node's style is an object of its own, so the new style branch copies each property onto it with Object.assign. When the style prop goes away or changes, dom.style.cssText = "" clears the old one first. React's DOM code does the same, one style property at a time.
This is step 5 without keys (in the app.js tab). Type a note beside Green, then press Mint first. Beside which tea is your note now? Then fix the bug.
const h = createElement;const list = document.getElementById("list");const log = document.getElementById("log");let teas = ["Green", "Black", "Mint"]; function show(next) { teas = next; const rows = teas.map((tea) => h("li", null, tea, " ", h("input", { placeholder: "A note about " + tea }))); render(h("ul", null, rows), list, (report) => { log.textContent = report.changes.join("\n"); });} document.getElementById("mint-first").addEventListener("click", () => show(["Mint", ...teas.filter((tea) => tea !== "Mint")]));document.getElementById("add-chai").addEventListener("click", () => show([...teas.filter((tea) => tea !== "Chai"), "Chai"]));document.getElementById("drop-green").addEventListener("click", () => show(teas.filter((tea) => tea !== "Green")));show(teas);const h = createElement;const list = document.getElementById("list");const log = document.getElementById("log");let teas = ["Green", "Black", "Mint"]; function show(next) { teas = next; const rows = teas.map((tea) => h("li", { key: tea }, tea, " ", h("input", { placeholder: "A note about " + tea }))); render(h("ul", null, rows), list, (report) => { log.textContent = report.changes.join("\n"); });} document.getElementById("mint-first").addEventListener("click", () => show(["Mint", ...teas.filter((tea) => tea !== "Mint")]));document.getElementById("add-chai").addEventListener("click", () => show([...teas.filter((tea) => tea !== "Chai"), "Chai"]));document.getElementById("drop-green").addEventListener("click", () => show(teas.filter((tea) => tea !== "Green")));show(teas);Without keys, the first old li (with your note in its input) is matched with the first new child, Mint, so the commit only changes its text to Mint, and the note stays in that row. With key: tea, Green's li is moved instead, and the note moves with it.
The use-reducer.js tab is loaded after tiny-react.js. Write useReducer(reducer, initialState) with the useState you already have, so the cart's buttons work.
// Write useReducer with the useState you already have.function useReducer(reducer, initialState) { // Replace this line: its dispatch never changes anything. return [initialState, () => {}];}// useReducer is useState with a reducer applied to each action.function useReducer(reducer, initialState) { const [state, setState] = useState(initialState); const dispatch = (action) => setState((current) => reducer(current, action)); return [state, dispatch];}useReducer is useState whose updates are functions: each dispatch queues (current) => reducer(current, action), and the next render applies them in order. React's own useState is the other way round: it's useReducer with a built-in reducer, basicStateReducer.
The use-state.js tab replaces useState. Pressing Green when the tea is already Green still renders. Make dispatch skip the render when the state wouldn't change. Then press Green, Green, Black, Black, Green: what does the page say?
// This replaces the library's useState. Skip the render when nothing would change.function useState(initialState) { const old = renderingFiber.alternate?.hooks[hookIndex]; const queue = old ? old.queue : { pending: [], dispatch: null }; let state = old ? old.state : typeof initialState === "function" ? initialState() : initialState; for (const action of queue.pending) { state = typeof action === "function" ? action(state) : action; } queue.dispatch ??= (action) => { queue.pending.push(action); startRender(currentRoot ?? workInProgressRoot); }; renderingFiber.hooks[hookIndex++] = { state, queue, applied: queue.pending.length }; return [state, queue.dispatch];}// This replaces the library's useState. Like React's eager bailout: if nothing is// waiting and the new state is the one just rendered, don't start a render.function useState(initialState) { const old = renderingFiber.alternate?.hooks[hookIndex]; const queue = old ? old.queue : { pending: [], dispatch: null, lastRendered: undefined }; let state = old ? old.state : typeof initialState === "function" ? initialState() : initialState; for (const action of queue.pending) { state = typeof action === "function" ? action(state) : action; } queue.lastRendered = state; queue.dispatch ??= (action) => { const next = typeof action === "function" ? action(queue.lastRendered) : action; if (queue.pending.length === 0 && Object.is(next, queue.lastRendered)) return; queue.pending.push(action); startRender(currentRoot ?? workInProgressRoot); }; renderingFiber.hooks[hookIndex++] = { state, queue, applied: queue.pending.length }; return [state, queue.dispatch];}When nothing is waiting in the queue, dispatch can work out the next state at once and compare it with the last one rendered. If they're the same, there's nothing to render. React does this check too, the eager state bailout in dispatchSetState.
Check your understanding
Question 1 of 7What does tiny React's
createElement("p", null, "Hi")return?Choose an answer to see the explanation.
Question 2 of 7Why does the work loop check
shouldYield()after every fiber?Choose an answer to see the explanation.
Question 3 of 7Why does tiny React change the DOM only in the commit?
Choose an answer to see the explanation.
Question 4 of 7The keyed list
Green, Black, MintbecomesMint, Green, Black. Which fibers getPlacement?Choose an answer to see the explanation.
Question 5 of 7A click runs this handler. How many times does tiny React render?
Read the code, then predictfunction orderThree() { setOrders((n) => n + 1); setOrders((n) => n + 1); setOrders((n) => n + 1); }Choose an answer to see the explanation.
Question 6 of 7Why must hooks be called in the same order on every render, in tiny React as in React?
Choose an answer to see the explanation.
Question 7 of 7When does tiny React run an effect whose dependencies changed?
Choose an answer to see the explanation.
Key takeaways
- Elements are plain objects that describe the page:
type,keyandprops. - Fibers link each element to its child, sibling and parent, so a loop can do one at a time and pause.
- A
MessageChanneltask with a 5 ms budget is enough to keep the page responsive during a long render. - Reconciliation matches children by key and type, keeps DOM nodes it can, and marks moves with
Placement. - The commit changes the DOM in one go, after the whole tree is ready.
- Hooks live on the fiber in call order;
setStatequeues an update and asks for a render. - Effects run after the commit, in a later task, when their dependencies change.
React describes the page with elements, works through fibers in small pieces, compares them with the last render, and commits the changes at once.
You've now built every part of that loop yourself.
That's the end of the React course. You started with why React exists and your first component, learned state, effects and the rest of the everyday API, then data loading, performance, patterns and the ecosystem, React on the server, and finally the source code under all of it. The best next step is to build something of your own, and to open React's source whenever something surprises you: you now know where to look.
Coming next: the React path, where you can review any lesson and see your progress.