Implement useState and useEffect from Scratch
Problem
Implement basic working versions of React's useState and useEffect from scratch, without relying on React internals — explaining how hooks associate state with a component across renders.
Requirements
useState(initial) -> [value, setValue]— persists across renders,setValueschedules a re-renderuseEffect(callback, deps?)— runs on mount, re-runs only when deps change, and runs its returned cleanup before the next invocation and on unmount- Multiple independent hooks per component must not interfere
- No deps argument runs every render;
[]runs once
Core design
- The slot array + cursor is the whole mechanism. Maintain a module-level array of hook slots plus a cursor index that resets to 0 before each render. Each
useStatecall reads/writes the slot at the current cursor, then advances it. The hook has no name and no key — it is identified purely by call order. useState(initial): if the slot is empty, seed it withinitial. Return[slot.value, setValue], wheresetValue(next)writes the slot and triggers a re-render. On the next render the cursor walks the same sequence and each call lands on its own slot again.useEffect(cb, deps): store the previous deps array alongside the callback in the slot. On each render, shallow-compare (Object.isper element) new deps to stored. If they differ — or it's the first mount, or deps is undefined — run the stored cleanup from the last invocation, then invokecband stash its return value as the next cleanup.- Effects must be deferred until after the render commits, not run inline during it — otherwise an effect that sets state loops synchronously.
- Cleanup ordering is the subtle part: previous cleanup runs before the next effect, not after, so a subscription is torn down before its replacement is created.
- Real React holds this slot list on the fiber node per component instance, not a module global — which is exactly why two components using the same hook don't collide.
Discussion points
- This implementation is why the rules of hooks exist: a hook inside a conditional or loop shifts the cursor between renders, so slot 1 returns slot 0's state and everything silently desynchronizes. The constraint isn't stylistic — it's forced by index-based identity.
- Trade-off of call-order identity: zero boilerplate and no keys, at the cost of a rigid call-order requirement. A map keyed by an explicit hook name would remove the rule but push naming onto every call site.
- Stale closures: an effect captures the
valuefrom the render it was created in. Omitting it from deps means the effect reads a frozen value forever — the single most common hooks bug, and it falls directly out of this design. setValuewith a function updater (setValue(v => v + 1)) exists precisely because the capturedvaluemay be stale; batching multiplesetValuecalls in one tick requires a queue rather than an immediate write.- Shallow comparison means an object or array literal in deps is a new reference every render, so the effect never memoizes — the same identity problem
useMemoaddresses. - A module-global slot array supports exactly one component instance; extending to many requires per-instance state, which is the fiber design.
- Extension:
useMemo/useCallbackare the same deps-comparison machinery with a cached value instead of a side effect — implementing one gives the others nearly free.
asked …