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
setStatecalls 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 usedPromise.prototype.thenfor the same effect. Preact 11 callsqueueMicrotaskdirectly.) - 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_DIRTYagain.
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
schedulerpackage yields to the browser about every 5 ms, posting tasks withMessageChannel(§15) becausesetTimeout(0)is clamped andrequestIdleCallbackfires too rarely. That machinery powers concurrent features likeuseTransition, 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
pendingEffectsand readhook.effectwhen the timer fired. The second render overwrotehook.effectbefore 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).