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, setValue schedules a re-render
  • useEffect(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 useState call 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 with initial. Return [slot.value, setValue], where setValue(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.is per 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 invoke cb and 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 value from 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.
  • setValue with a function updater (setValue(v => v + 1)) exists precisely because the captured value may be stale; batching multiple setValue calls 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 useMemo addresses.
  • A module-global slot array supports exactly one component instance; extending to many requires per-instance state, which is the fiber design.
  • Extension: useMemo/useCallback are the same deps-comparison machinery with a cached value instead of a side effect — implementing one gives the others nearly free.
asked …
LeaderboardSalaryAccount