Part 6 · 2 chapters · ~20 min
M6: UI Framework
React's architecture in miniature: virtual nodes, a fiber tree walked one unit at a time with a yield check, 5 ms time slices on MessageChannel, keyed reconciliation that moves instead of re-creating, hooks in call order with queued updates, and a synchronous commit followed by effects.
12
Fibers, hooks and time slicing
work out what changed, then apply it
- setState queues the update and schedules one render.
- Fibers have child, sibling and parent pointers, so the tree can be walked without recursion.
- Units of work, with a yield check after each one.
- Yielding in 5 ms slices lets input and paint happen in between.
- Keyed diffing marks moves and insertions and reuses hooks.
- The commit applies everything at once, then runs effects.
where this connects
The React course parts 1 to 5 describe real Fiber, lanes and concurrent rendering. tiny-react has the render/commit split, keyed reconciliation, hooks in call order and time slicing, but no lanes. Every render here has the same priority, which is why lanes are the first exercise. The Browser course part 6 explains why a MessageChannel message is the right way to yield.
TINY-REACT: RENDER IN SLICES, COMMIT AT ONCE
a setState becoming a fiber walk that can pause between units, then a single synchronous commit
swipe the figure sideways, or tap expand for full screen
1/6
setState
setState queues: two calls to set(x => x + 1) in one handler append two updates to the hook's queue and schedule one re-render. Nothing renders yet; the scheduler will start a task.
13
Building it: tiny-react
Repo: repos/uiframework. h, the reconciler, hooks and the scheduler.
code
// src/reconciler.ts: the interruptible loop
function workLoop(): boolean {
while (next) {
next = performUnit(next);
if (next && shouldYield()) return true; // more work: the scheduler calls again next slice
}
if (wip) commitRoot(); // finished: apply everything at once
return false;
}
function performUnit(f: Fiber): Fiber | null {
if (typeof f.type === 'function') {
currentFiber = f; hookIndex = 0; f.hooks = [];
const out = (f.type as Component)(f.props); // hooks read slots by call order
reconcileChildren(f, out ? [out] : []);
} else {
if (!f.dom && f.type !== 'root') f.dom = createDom(f); // created, not yet attached
reconcileChildren(f, f.props.children ?? []);
}
if (f.child) return f.child;
let n: Fiber | null = f;
while (n) { if (n.sibling) return n.sibling; n = n.parent; }
return null;
}code
// src/hooks.ts: useState applies queued updates in order, then hands back a stable setter
export function useState<T>(initial: T | (() => T)) {
const { f, i, old } = slot();
const hook = old ?? { kind: 'state', value: typeof initial === 'function' ? (initial as () => T)() : initial, queue: [] };
for (const u of hook.queue) hook.value = typeof u === 'function' ? u(hook.value) : u;
hook.queue = [];
hook.set ??= (v: any) => { hook.queue.push(v); scheduleRerender(); };
f.hooks[i] = hook;
return [hook.value, hook.set];
}Run it. In
repos/uiframework: npm test (the last test asserts that a 300-row render spans several scheduler slices), then npm run build and open examples/index.html. Type quickly while 2,000 deliberately slow rows re-render. Switch the slice to 1000 ms to feel what a blocking render does to input.a bug the tests found
The first test run never finished. In Node, an open MessageChannel port keeps the process alive, so the scheduler's port held the test runner open. The browser never shows this. The fix is to call
unref() on both ports when the runtime supports it. Environments differ in ways that only show up when you run in both.exercises
1. Lanes: give input updates a higher priority that interrupts and restarts a transition render. 2. useTransition on top of lanes. 3. Context via a provider fiber lookup. 4. Error boundaries that catch in render and commit a fallback. 5. memo() that bails out of a subtree when props are shallowly equal.