Frontend Labs
⋯

70. Preact: a whole React in a few kilobytes

Preact implements React's component model, virtual DOM and hooks in a fraction of the size. Its source is short enough to read in an afternoon. src/ is about 2,000 lines, and hooks/src/index.js is 571.

createElement: JSX becomes plain objects

// src/create-element.js (Preact 11)
export function createElement(type, props, children) {
	let normalizedProps = {}, key, ref, i, length = arguments.length;
	for (i in props) {
		if (i == 'key') key = props[i];
		else if (i == 'ref' && typeof type != 'function') ref = props[i];
		else normalizedProps[i] = props[i];
	}
	if (length > 2) {
		normalizedProps.children = length > 3 ? slice.call(arguments, 2) : children;
	}
	return createVNode(type, normalizedProps, key, ref, NULL);
}

key and ref are pulled out of props (which is why props.key is undefined in a component). Children are collected from the extra arguments. A VNode is a plain object, which V8 can keep monomorphic because every VNode is created with the same shape in createVNode (§49).

setState is batched into one microtask

// src/component.js (Preact 11)
export function enqueueRender(c) {
	if (
		(!(c._bits & COMPONENT_DIRTY) &&
			(c._bits |= COMPONENT_DIRTY) &&
			rerenderQueue.push(c) &&
			!rerenderCount++) ||
		prevDebounce != options.debounceRendering
	) {
		prevDebounce = options.debounceRendering;
		(prevDebounce || queueMicrotask)(process);
	}
}

const depthSort = (a, b) => a._vnode._depth - b._vnode._depth;

function process() {
	try {
		let c, l = 1;
		while (rerenderQueue.length) {
			// Keep the rerender queue sorted by (depth, insertion order). …
			if (rerenderQueue.length > l) rerenderQueue.sort(depthSort);
			c = rerenderQueue.shift();
			l = rerenderQueue.length;
			if (c._bits & COMPONENT_DIRTY) renderComponent(c);
		}
	} finally {
		rerenderQueue.length = rerenderCount = 0;
	}
}

Three ideas from earlier chapters, in about 30 lines:

  • Dirty bit. A component already in the queue isn't added twice, so ten setState calls in one handler cause one render.
  • Microtask flush (§12–§13). The queue is processed with queueMicrotask, after the current handler finishes but before the browser paints, so users never see intermediate state. (Preact 10 used Promise.prototype.then for the same effect. Preact 11 calls queueMicrotask directly.)
  • Parents before children. Sorting by tree depth means that when a parent and its child are both dirty, the parent renders first. Its render usually re-renders the child, whose dirty bit is then cleared, so the child's queue entry is skipped. That's why the loop checks c._bits & COMPONENT_DIRTY again.

Hooks: an array, indexed by call order

The "rules of hooks" (don't call them in conditions or loops) seem arbitrary until you see the data structure:

// hooks/src/index.js (Preact 11)
options._render = vnode => {
	currentComponent = vnode._component;
	currentIndex = 0;                      // every render starts counting from 0
	// …
};

function getHookState(index, type) {
	const hooks =
		currentComponent.__hooks ||
		(currentComponent.__hooks = { _list: [], _pendingEffects: [] });
	if (index >= hooks._list.length) hooks._list.push({});
	return hooks._list[index];
}

export function useState(initialState) {
	currentHook = 1;
	return useReducer(invokeOrReturn, initialState);   // useState IS useReducer
}

export function useReducer(reducer, initialState, init) {
	const hookState = getHookState(currentIndex++, 2);
	// … on first call:
	hookState._value = [
		!init ? invokeOrReturn(undefined, initialState) : init(initialState),
		action => {
			const currentValue = hookState._nextValue ? hookState._nextValue[0] : hookState._value[0];
			const nextValue = hookState._reducer(currentValue, action);
			if (!ObjectIs(currentValue, nextValue)) {        // same value? no render at all
				hookState._nextValue = [nextValue, hookState._value[1]];
				hookState._component.setState({});           // reuse the class-component queue
			}
		}
	];
	// …
}

The second useState in a component is "the hook at index 1" and nothing more. Put it inside an if and the next render's index 1 is a different hook, which now has the wrong state. The Object.is check is why setting state to the same value doesn't re-render. Both hooks types end up calling the old class component's setState({}), so they share the batching above.

Effects run after paint. useEffect callbacks are collected during render and flushed by afterNextFrame:

// hooks/src/index.js (Preact 11)
function afterNextFrame(callback) {
	const done = () => {
		clearTimeout(timeout);
		if (HAS_RAF) cancelAnimationFrame(raf);
		setTimeout(callback);
	};
	const timeout = setTimeout(done, RAF_TIMEOUT);   // 35ms fallback: rAF doesn't fire in background tabs
	let raf;
	if (HAS_RAF) raf = requestAnimationFrame(done);
}

requestAnimationFrame fires just before the next paint (§15). The setTimeout inside it runs as a task after that paint, so effects never delay the first pixels. That's the difference between useEffect (after paint) and useLayoutEffect (before paint, which blocks it). The 35 ms timeout covers hidden tabs, where rAF is paused.

Keyed diffing: search outward from where it should be

// src/diff/children.js (Preact 11), abridged
function findMatchingIndex(childVNode, oldChildren, skewedIndex, remainingOldChildren) {
	const key = childVNode.key, type = childVNode.type;
	let oldVNode = oldChildren[skewedIndex];
	const matched = oldVNode && !(oldVNode._flags & MATCHED);
	// The ternary keeps this a Smi comparison; `> matched` would compare
	// number to boolean which V8 can't serve from the fast path.
	let shouldSearch = remainingOldChildren > (matched ? 1 : 0);

	if ((oldVNode === NULL && key == NULL) || (matched && key == oldVNode.key && type == oldVNode.type)) {
		return skewedIndex;                         // the common case: same place, O(1)
	} else if (shouldSearch) {
		let x = skewedIndex - 1, y = skewedIndex + 1;
		while (x >= 0 || y < oldChildren.length) { // walk outward in both directions
			const childIndex = x >= 0 ? x-- : y++;
			oldVNode = oldChildren[childIndex];
			if (oldVNode && !(oldVNode._flags & MATCHED) && key == oldVNode.key && type == oldVNode.type) return childIndex;
		}
	}
	return -1;                                       // new node: create it
}

Most list updates touch one item or shift everything by one, so checking the expected position first, then its neighbours, is fast in practice without the memory cost of building a key→index Map on every diff. Note the comment: it is about keeping a comparison on V8's fast path for small integers (Smis, §49). Hot library code gets tuned at that level.

Events: one listener per event type, swapped handlers for free

// src/diff/props.js (Preact 11), abridged
} else if (name[0] == 'o' && name[1] == 'n') {
	// onClick -> "click"; onClickCapture -> "click" + capture
	(dom._listeners || (dom._listeners = {}))[name + useCapture] = value;
	if (value) {
		if (!oldValue) dom.addEventListener(name, useCapture ? eventProxyCapture : eventProxy, useCapture);
	} else {
		dom.removeEventListener(name, useCapture ? eventProxyCapture : eventProxy, useCapture);
	}
}
// …
function createEventProxy(useCapture) {
	return function (e) {
		if (this._listeners) {
			const eventHandler = this._listeners[e.type + useCapture];
			// … (guards against handlers attached during the same dispatch)
			return eventHandler(options.event ? options.event(e) : e);
		}
	};
}

Every element uses the same eventProxy function as its real listener. The actual handler is looked up in dom._listeners at dispatch time. When a re-render passes a new arrow function for onClick (which happens on every render), Preact only swaps the entry in that object. It never calls removeEventListener/addEventListener again. Contrast React, which attaches one listener per event type at the root and simulates bubbling itself (event delegation, §23).

Compared with React. React's Fiber architecture splits rendering into interruptible units of work. Its scheduler package yields to the browser about every 5 ms, posting tasks with MessageChannel (§15) because setTimeout(0) is clamped and requestIdleCallback fires too rarely. That machinery powers concurrent features like useTransition, and it is a big part of why React is many times Preact's size. Preact renders synchronously inside its microtask flush: less flexible, much smaller.

Build it: hooks in 60 lines

This mini version keeps the index-array storage, the microtask batching, the Object.is bail-out and after-paint effects:

let current = null, index = 0;
const queue = new Set();
let scheduled = false;

function enqueueRender(instance) {
  queue.add(instance);                       // a Set: dirty-bit dedupe for free
  if (!scheduled) {
    scheduled = true;
    queueMicrotask(() => {                   // many setState calls -> ONE render
      scheduled = false;
      const batch = [...queue]; queue.clear();
      batch.forEach(render);
    });
  }
}

function render(instance) {
  current = instance; index = 0;
  instance.output = instance.component(instance.props);
  instance.renders++;
  current = null;
  const effects = instance.pendingEffects.splice(0);
  setTimeout(() => effects.forEach(({ hook, effect }) => {   // "after paint"
    hook.cleanup?.();
    hook.cleanup = effect() ?? undefined;
  }));
}

export function useState(initial) {
  const hooks = current.hooks, i = index++, instance = current;
  if (!(i in hooks)) {
    hooks[i] = {
      value: typeof initial === "function" ? initial() : initial,
      set: (next) => {
        const value = typeof next === "function" ? next(hooks[i].value) : next;
        if (Object.is(value, hooks[i].value)) return;       // bail out: no render
        hooks[i].value = value;
        enqueueRender(instance);
      },
    };
  }
  return [hooks[i].value, hooks[i].set];
}

export function useEffect(effect, deps) {
  const hooks = current.hooks, i = index++, old = hooks[i];
  const changed = !old || !deps || deps.some((d, k) => !Object.is(d, old.deps[k]));
  if (!old) hooks[i] = { deps };
  if (changed) {
    hooks[i].deps = deps;
    current.pendingEffects.push({ hook: hooks[i], effect });   // capture THIS render's callback
  }
}

export function mount(component, props) {
  const instance = { component, props, hooks: [], pendingEffects: [], renders: 0, output: null };
  render(instance);
  return instance;
}
function Counter({ step }) {
  const [count, setCount] = useState(0);
  useEffect(() => console.log(`effect: count is ${count}`), [count]);
  Counter.inc = () => { setCount((c) => c + step); setCount((c) => c + step); setCount((c) => c + step); };
  return `${count} clicks`;
}
const app = mount(Counter, { step: 1 });   // "0 clicks", renders: 1
Counter.inc(); await null;                 // "3 clicks", renders: 2 (three updates, one render)
// effects, after "paint": "effect: count is 0", then "effect: count is 3"

War story from writing this chapter. The first draft pushed the hook object onto pendingEffects and read hook.effect when the timer fired. The second render overwrote hook.effect before the first render's timer ran, so the log said "count is 3" twice and never "count is 0". A closure captured too late is the same bug behind React's "stale closure" and "effect ran with new props" reports. Capture the value you mean at the time you mean it (§6).

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36