cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

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.

By the end, you can
  • 01
    Build React's core loopWrite createElement, a fiber work loop that pauses every 5 ms, keyed reconciliation and a commit, in plain JavaScript.
  • 02
    Write hooks from scratchKeep useState and useEffect on the fiber in call order, with an update queue and effects that run after the commit.
  • 03
    Map 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:

A tea shop running on tiny ReactPop out in the code editor (opens in a new tab)JavaScript
// 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;    }  }}
ResultLoading…

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.

Two columns of seven cards. Left, tiny React's functions; right, the React file that does the same job. createElement and ReactJSXElement.js; render and createDom and ReactFiberConfigDOM.js; workLoop and ReactFiberWorkLoop.js; reconcileChildren and ReactChildFiber.js; commitRoot and ReactFiberCommitWork.js; useState and ReactFiberHooks.js; useEffect and ReactFiberHooks.js.tiny-react.jsReact 19.3.1createElementReactJSXElement.jsrender, createDomReactFiberConfigDOM.jsworkLoopReactFiberWorkLoop.jsreconcileChildrenReactChildFiber.jscommitRootReactFiberCommitWork.jsuseStateReactFiberHooks.jsuseEffectReactFiberHooks.js
Each step of the lesson writes one function of 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.
Real-life analogyA working model of a train station

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
The main idea

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:

New in step 1: createElementJavaScript
// 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: [] } };
}
  1. Line 2 takes the element's type, its props object (config), and any number of children, gathered by the ...children rest parameter.
  2. Line 3 takes key out of the props, the way React does, and keeps the rest in props.
  3. 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.
  4. Lines 8 to 13: toChildren flattens arrays, such as one made by map (line 10), drops null, undefined, true and false (line 11), and turns strings and numbers into text elements (line 12).
  5. Lines 15 to 17: a text element has the type "TEXT" and its text in nodeValue, 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:

Step 1: print an elementPop out in the code editor (opens in a new tab)JavaScript
// 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: [] } };}
ResultLoading…

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:

The element object in ReactJSXElement.js, lines 226 to 237JavaScript
// 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:

New in step 2: a render that does it all at onceJavaScript
// 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();
}
  1. 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).
  2. Line 9: a text node for TEXT, an element for anything else.
  3. Lines 14 to 25: updateDom compares 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.
  4. Lines 27 to 29: onClick becomes the browser's click.
Step 2: a shop rendered from scratchPop out in the code editor (opens in a new tab)JavaScript
// 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();}
ResultLoading…

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:

commitTextUpdate in ReactFiberConfigDOM.js, lines 1018 to 1024JavaScript
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.

New in step 3: fibers and the work loopJavaScript
// 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);
  }
}
  1. Lines 2 to 4: the root fiber being built, the next fiber to work on, and when the current slice of time ends.
  2. Lines 6 to 10: a MessageChannel is the scheduler. Posting a message (line 13) asks the browser to call onmessage as a new task; between tasks, the browser can paint and handle clicks. Each task gets 5 ms (line 8).
  3. Lines 16 to 18: shouldYield says when those 5 ms are up.
  4. Lines 20 to 29: render makes the root fiber, which holds the container and the element, and asks for a task. The onCommit callback is only for this lesson's experiments; React has no such argument.
  5. 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).
  6. 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), or null when the tree is done (line 48).
  7. Lines 51 to 60, beginWork: make the fiber's DOM node, then a child fiber for each child element, linked by child and sibling.
  8. Lines 62 to 74, the commit: only when every fiber is done, add every node to its parent, in one go.
Six fibers. root at the top; an arrow labeled child goes down to ul; another child arrow to li Green, which has a child arrow to the text Green. An arrow labeled sibling goes right from li Green to li Black, which has a child arrow to the text Black. A curved arrow labeled return goes from the text Green back up to li Green.rootchildulchildli Greensiblingli Black"Green""Black"return goes back up
Every fiber points to its first 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")):

Playground: follow the work loop through a small list
The fibersnext: root
  1. root
  2. ul
  3. li Green
  4. "Green"
  5. li Black
  6. "Black"
Where to go next
  1. if (fiber.child) return fiber.child;
  2. for (let next = fiber; next; next = next.return)
  3. if (next.sibling) return next.sibling;
  4. return null;
Step 0 of 6
Before the first unit of work

workInProgress is the root fiber. Press Step to run performUnitOfWork once.

A replay written for this lesson: the lesson test runs step 3's tiny-react.js on the same tree and checks that it visits the fibers in this order.

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:

Step 3: the same 20,000 teas, in slicesPop out in the code editor (opens in a new tab)JavaScript
// 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);  }}
ResultLoading…

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:

workLoopConcurrentByScheduler in ReactFiberWorkLoop.js, lines 3073 to 3079JavaScript
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:

shouldYieldToHost in Scheduler.js, lines 459 to 472JavaScript
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:

New in step 4: reconcileChildrenJavaScript
// 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]);
}
  1. Lines 3 to 6: put the old children in a Map, by key, or by position for children with no key.
  2. 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.
  3. 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.
  4. 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.
  5. 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 gets Placement too, which means "move". lastPlacedIndex is the old position of the last fiber that stayed.
  6. Line 33: old fibers no one matched are kept in deletions.
  7. Lines 36 to 44 are helpers: report writes a line for this lesson's log, and changedProps says whether any prop besides children changed.
Top row, the old children with their index: Green 0, Black 1, Mint 2. Bottom row, the new order: Mint, Green, Black. Mint is matched first and stays, so lastPlacedIndex becomes 2. Green's old index 0 is less than 2: it gets Placement and moves. Black's old index 1 is less than 2: it moves too.beforeGreen, index 0Black, index 1Mint, index 2MintGreenBlackafterstays: 2 is the highestmoves: 0 is less than 2moves: 1 is less than 2
Matching by key, the new list visits Mint first: its old index, 2, is the highest so far, so it stays. Green (0) and Black (1) were before it, so both get 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:

Step 4: what reconciliation decidesPop out in the code editor (opens in a new tab)JavaScript
// 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;  }}
ResultLoading…

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:

placeChild in ReactChildFiber.js, lines 511 to 541JavaScript
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;
  }
}
  1. Line 13: current is the old fiber, our old.
  2. Lines 15 to 21: if its old index is less than lastPlacedIndex, it's a move: flags |= Placement.
  3. Lines 22 to 24: otherwise it stays, and its old index becomes the new lastPlacedIndex.
  4. 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.

New in step 5: the commitJavaScript
// 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;
}
  1. Lines 2 to 8: commit the tree, then make it the tree on the screen (line 5), so the next render compares against it.
  2. Line 11: first remove the fibers reconciliation didn't match.
  3. Lines 13 to 16: a fiber with Placement is inserted before the next node that's already in place. For a fiber that already had a DOM node, insertBefore moves it.
  4. Lines 17 to 20: a kept fiber whose props changed gets updateDom with its old and new props.
  5. 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:

hostParent, nodeAfter and firstPlacedNode in tiny-react.jsJavaScript
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:

Step 5: the commit moves nodesPop out in the code editor (opens in a new tab)JavaScript
// 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;}
ResultLoading…

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:

insertBefore in ReactFiberConfigDOM.js, lines 1135 to 1146JavaScript
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 end of flushMutationEffects in ReactFiberWorkLoop.js, lines 4047 to 4051JavaScript
// 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.

New in step 6: components and useStateJavaScript
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];
}
  1. 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 hooks array (line 5), and call the component to get its children (line 6).
  2. Line 18: find this hook in the old fiber, by position.
  3. Line 19: the queue is shared between renders: it holds the updates waiting to be applied, and the dispatch function.
  4. 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.
  5. Lines 24 to 27: dispatch, your setState, 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.
  6. Line 28: keep the hook in the next place, with how many updates it applied. The commit removes those from the queue.
Left, a card: Shop's fiber. An arrow labeled hooks goes to two cards: hooks 0, teas, and hooks 1, orders 0. From hooks 1, an arrow labeled queue goes to three cards in a row: n plus 1, n plus 1, n plus 1. Below: the next render applies them in order, 0 to 1 to 2 to 3, and Order 3 shows 3.Shop fiberhooks[0] teas[1] orders: 0queuen => n + 1n => n + 1n => n + 1the next render: 0, then 1, then 2, then 3one render shows Orders: 3
Each 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.
Step 6: a shop with statePop out in the code editor (opens in a new tab)JavaScript
// 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];}
ResultLoading…

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:

mountStateImpl in ReactFiberHooks.js, lines 1936 to 1945JavaScript
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:

New in step 7: useEffectJavaScript
// 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;
    }
  }
}
  1. 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.
  2. Lines 11 to 14: when a fiber is deleted, collect the cleanups of every hook below it.
  3. 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:

Step 7: effects and cleanupsPop out in the code editor (opens in a new tab)JavaScript
// 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;    }  }}
ResultLoading…

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:

areHookInputsEqual in ReactFiberHooks.js, lines 494 to 503JavaScript
  // $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:

What React 19 adds to each part of tiny React.
The jobWhat tiny React doesWhat React does
Choosing what to render firstOne 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 workOne task at a time, with a 5 ms budget.A scheduler with a priority queue of tasks, each with a deadline.
Interrupting a renderA 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 workRenders the whole tree on every update.Skips components whose props, state and context didn't change, using lanes on each fiber.
Reusing fibersMakes new fiber objects on every render.Keeps two trees, current and work in progress, and reuses each fiber's alternate.
EventsAdds a listener to each element.Listens once per event type at the root, and makes synthetic events with priorities.
HooksAn array of hooks and an array of updates.A linked list of hooks, and a ring of update objects, each with a lane.
The commitInserts, moves, updates and deletes nodes.Also attaches refs, runs layout effects before paint, and inserts a new subtree in one piece.
Server HTMLAlways makes new DOM nodes.Hydrates existing HTML from the server, and renders Server Components.
MemoizationNone.memo, useMemo and the React Compiler's cache skip repeated work.

Each row has a lesson that reads React's own code for it:

Tiny React, or only real React?
  • 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
Try it yourself
0 of 8 correct

Put each feature under the React that has it.

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

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:

The same shop on React 19Pop out in the code editor (opens in a new tab)JSX
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);}
ResultLoading…

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

Exercise 1 · Warm-upHow many objects?

With step 1's createElement, how many element objects does this line make, counting the p?

Starter codeJavaScript
const price = createElement("p", null, "Price: ", 4);

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

    Exercise 2 · Warm-upThe order of the walk

    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.

    Starter codeJavaScript
    h("ul", null, h("li", null, "Green"), h("li", null, "Black"))

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

      Exercise 3 · PracticeSupport style objects

      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.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      // 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;  }}
      ResultLoading…
        Exercise 4 · PracticeFind the bug: a list without keys

        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.

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

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

          Exercise 5 · ChallengeWrite useReducer

          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.

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          // Write useReducer with the useState you already have.function useReducer(reducer, initialState) {  // Replace this line: its dispatch never changes anything.  return [initialState, () => {}];}
          ResultLoading…
            Exercise 6 · ChallengeSkip a render that changes nothing

            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?

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

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

              Check your understanding

              The tiny React quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does tiny React's createElement("p", null, "Hi") return?

                Choose an answer to see the explanation.

              2. Question 2 of 7Why does the work loop check shouldYield() after every fiber?

                Choose an answer to see the explanation.

              3. Question 3 of 7Why does tiny React change the DOM only in the commit?

                Choose an answer to see the explanation.

              4. Question 4 of 7The keyed list Green, Black, Mint becomes Mint, Green, Black. Which fibers get Placement?

                Choose an answer to see the explanation.

              5. Question 5 of 7A click runs this handler. How many times does tiny React render?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                function orderThree() {
                  setOrders((n) => n + 1);
                  setOrders((n) => n + 1);
                  setOrders((n) => n + 1);
                }

                Choose an answer to see the explanation.

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

              7. 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, key and props.
              • Fibers link each element to its child, sibling and parent, so a loop can do one at a time and pause.
              • A MessageChannel task 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; setState queues 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.

              CompleteFrontend Clear concepts. Working examples.