112Implement a mini useState and useEffect hooks engine
Problem
Hooks look like magic: how does useState "remember" a value in a plain function? Build a tiny hooks engine to find out.
Implement mount(Component, props), useState, useEffect and useRef. A component is a plain function that returns any value (for example a string); mount returns an instance:
function Counter({ step }) {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return `count=${count}`;
}
const counter = mount(Counter, { step: 1 });
counter.output; // "count=0"Instance API: output (the last returned value), renders (how many times it rendered), setProps(props) (re-render now with new props), and unmount().
Rules
useState(initial)returns[state, setState].initialmay be a function (called only on the first render).setState(value | updater)schedules a re-render. Several calls in the same tick are batched into one render (use a microtask), and updaters apply in order. Setting the same value (Object.is) does not re-render.setStateis stable across renders and does nothing after unmount.useEffect(effect, deps)runs after the render. Withdepsomitted it runs after every render; with[]only once; otherwise when any dep changed (Object.is). Before re-running, and on unmount, call the cleanup the effect returned.useRef(initial)returns the same{ current }object on every render, and changing it never re-renders.- Instances are independent.
Try it yourself
Write your solution, run it, then submit to check it against the tests.
Press Run or ⌘ Enter to execute your code.
Step by step
Build the solution one idea at a time.
- 1
Hooks are an array plus an index
Each instance keeps
hooks = []. During a render, a globalcurrentpoints at the rendering instance and acursorstarts at 0. Every hook call useshooks[cursor++]. That is why hooks must be called in the same order every render: the position is the identity. - 2
useState
On the first call at a position, create
{ state, queue: [] }(callinginitialif it is a function).setStatepushes the action into the queue and schedules a render. During the next render, apply the queued actions in order to get the new state. CreatesetStateonce and store it in the hook, so its identity never changes. - 3
Batch with a microtask
schedule()sets apendingflag and callsqueueMicrotask(flush)only if no render is pending yet. ThreesetStatecalls in one event therefore cause one render. Before rendering, check whether any state actually changed (Object.is); if not, skip it. - 4
useEffect: record, then run after the render
During render, compare the deps with the previous deps stored in the hook. If they changed, mark the effect as pending, but do not run it yet. After the component function returns, run the pending effects in order: first the previous cleanup, then the new effect, storing its return value as the next cleanup.
- 5
Unmount
Run every stored cleanup and mark the instance unmounted, so later
setStatecalls are ignored.
Approach
A per-instance hooks array indexed by call order, a global "currently rendering instance", microtask-batched re-renders driven by state queues, and an effect list collected during render and flushed after it.
Solution
let current = null;
function mount(Component, props = {}) {
const instance = {
hooks: [],
cursor: 0,
props,
output: undefined,
renders: 0,
pending: false,
unmounted: false,
effects: [],
};
function render() {
instance.cursor = 0;
instance.effects = [];
const previous = current;
current = instance;
try {
instance.output = Component(instance.props);
instance.renders++;
} finally {
current = previous;
}
for (const hook of instance.effects) {
hook.cleanup?.();
const cleanup = hook.effect();
hook.cleanup = typeof cleanup === "function" ? cleanup : undefined;
}
}
instance.schedule = () => {
if (instance.pending || instance.unmounted) return;
instance.pending = true;
queueMicrotask(() => {
instance.pending = false;
if (instance.unmounted) return;
const changed = instance.hooks.some((hook) => hook.kind === "state" && hook.queue.length > 0 && hook.wouldChange());
if (changed) render();
else instance.hooks.forEach((hook) => hook.kind === "state" && (hook.queue = []));
});
};
render();
return {
get output() {
return instance.output;
},
get renders() {
return instance.renders;
},
setProps(nextProps) {
instance.props = nextProps;
render();
},
unmount() {
instance.unmounted = true;
for (const hook of instance.hooks) hook.cleanup?.();
},
};
}
function nextHook(create) {
if (!current) throw new Error("Hooks can only be called while a component is rendering");
const index = current.cursor++;
current.hooks[index] ??= create(current);
return current.hooks[index];
}
const apply = (state, action) => (typeof action === "function" ? action(state) : action);
function useState(initial) {
const hook = nextHook((instance) => {
const h = {
kind: "state",
state: typeof initial === "function" ? initial() : initial,
queue: [],
};
h.wouldChange = () => !Object.is(h.queue.reduce(apply, h.state), h.state);
h.setState = (action) => {
if (instance.unmounted) return;
h.queue.push(action);
instance.schedule();
};
return h;
});
if (hook.queue.length > 0) {
hook.state = hook.queue.reduce(apply, hook.state);
hook.queue = [];
}
return [hook.state, hook.setState];
}
function useEffect(effect, deps) {
const instance = current;
const hook = nextHook(() => ({ kind: "effect", deps: undefined, cleanup: undefined, ran: false }));
const changed = !hook.ran || deps === undefined || deps.length !== hook.deps.length || deps.some((d, i) => !Object.is(d, hook.deps[i]));
if (changed) {
hook.ran = true;
hook.deps = deps;
hook.effect = effect;
instance.effects.push(hook);
}
}
function useRef(initial) {
return nextHook(() => ({ kind: "ref", current: initial }));
}Complexity
Time: O(h) per render for h hooks, plus the queued updates. Space: O(h) per instance.
Edge cases
- Three
setStatecalls in one tick cause one render, with updaters applied in order. - Setting the same value does not re-render.
setStatekeeps the same identity across renders.useEffectwith[]runs once; without deps it runs every render; cleanups run before re-runs and on unmount.setStateafter unmount is ignored.
Follow-up questions
Answer each one out loud first, then reveal the model answer.