FrontendPro

112Implement a mini useState and useEffect hooks engine

hardAbout 60 minutesCodingFreereact-internalshooksclosuresscheduling

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]. initial may 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. setState is stable across renders and does nothing after unmount.
  • useEffect(effect, deps) runs after the render. With deps omitted 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.

solution.jsJavaScript

Press Run or ⌘ Enter to execute your code.

Step by step

Build the solution one idea at a time.

  1. 1

    Hooks are an array plus an index

    Each instance keeps hooks = []. During a render, a global current points at the rendering instance and a cursor starts at 0. Every hook call uses hooks[cursor++]. That is why hooks must be called in the same order every render: the position is the identity.

  2. 2

    useState

    On the first call at a position, create { state, queue: [] } (calling initial if it is a function). setState pushes the action into the queue and schedules a render. During the next render, apply the queued actions in order to get the new state. Create setState once and store it in the hook, so its identity never changes.

  3. 3

    Batch with a microtask

    schedule() sets a pending flag and calls queueMicrotask(flush) only if no render is pending yet. Three setState calls in one event therefore cause one render. Before rendering, check whether any state actually changed (Object.is); if not, skip it.

  4. 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. 5

    Unmount

    Run every stored cleanup and mark the instance unmounted, so later setState calls 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 setState calls in one tick cause one render, with updaters applied in order.
  • Setting the same value does not re-render.
  • setState keeps the same identity across renders.
  • useEffect with [] runs once; without deps it runs every render; cleanups run before re-runs and on unmount.
  • setState after unmount is ignored.

Follow-up questions

Answer each one out loud first, then reveal the model answer.