Skip to main content
React hooks are implemented using a queue data structure on each Fiber node. Understanding this implementation reveals why the Rules of Hooks exist and how React tracks state across renders.

Hooks queue and cursor

Each function component has a hooks queue stored on its Fiber node. This queue maintains all hooks called during rendering in a strict order.
The hooks queue is a linked list where each node represents a hook call. React uses a cursor to traverse this list during each render.

How the queue works

1

First render

When a component renders for the first time, React creates a new hook node for each hook call and appends it to the queue.
2

Subsequent renders

On re-renders, React resets the cursor to the beginning of the queue and walks through it, matching each hook call to its corresponding node.
3

State retrieval

When a hook is called, React uses the cursor to find the hook node, retrieves its state, and advances the cursor to the next node.
4

Updates

When state is updated (e.g., via setState), React stores the new value in the hook node and schedules a re-render.
Each hook node in the queue contains:
  • memoizedState: The current state value
  • baseState: The base state before updates
  • queue: Pending updates for this hook
  • baseQueue: Updates that weren’t processed
  • next: Pointer to the next hook node

The cursor pattern

React maintains a global cursor that points to the current hook being processed:
// Simplified implementation
let currentlyRenderingFiber = null;
let currentHook = null;
let workInProgressHook = null;

function renderWithHooks(fiber, Component, props) {
  currentlyRenderingFiber = fiber;
  currentHook = fiber.memoizedState; // Reset cursor to start
  
  const children = Component(props);
  
  currentlyRenderingFiber = null;
  currentHook = null;
  return children;
}

function useState(initialState) {
  // Move cursor to next hook node
  const hook = currentHook;
  currentHook = currentHook.next;
  
  return [hook.memoizedState, hook.dispatch];
}
This cursor-based approach is why you’ll see errors like “Rendered more hooks than during the previous render” - React detected a mismatch between the queue and the hook calls.

useEffect cleanup and dependency checking

The useEffect hook is implemented with a sophisticated system for tracking dependencies, scheduling effects, and managing cleanup functions.

Dependency checking

React compares dependencies using Object.is (shallow equality) to determine if an effect should run.
1

Store dependencies

On the first render, React stores the dependency array in the hook node.
2

Compare on re-render

On subsequent renders, React compares each new dependency with its corresponding previous dependency using Object.is.
3

Mark for execution

If any dependency changed (or if no dependency array is provided), React marks the effect to run during the commit phase.
4

Schedule effect

Effects marked for execution are added to the Fiber’s effect list and scheduled to run after the paint.
useEffect(() => {
  // Runs after every render
});
Without a dependency array, the effect runs after every render.

Cleanup function lifecycle

When an effect returns a cleanup function, React manages its execution carefully:
1

Store cleanup

When an effect runs, React stores its cleanup function in the hook node.
2

Run before next effect

Before running the effect again (if dependencies changed), React calls the previous cleanup function.
3

Run on unmount

When the component unmounts, React calls all cleanup functions during the commit phase.
Cleanup functions run synchronously during the commit phase, before the next effect runs. This ensures proper cleanup ordering and prevents memory leaks.
// Simplified implementation
function commitHookEffectListUnmount(effect) {
  const destroy = effect.destroy;
  if (destroy !== undefined) {
    destroy(); // Call cleanup
  }
}

function commitHookEffectListMount(effect) {
  const create = effect.create;
  effect.destroy = create(); // Call effect and store new cleanup
}

Effect timing

  • Runs asynchronously after the paint
  • Does not block the browser from updating the screen
  • Scheduled using requestIdleCallback or setTimeout
  • Best for most side effects (data fetching, subscriptions)

useRef mutable container

The useRef hook provides a mutable container that persists across renders without causing re-renders when mutated.
Unlike state, updating a ref’s current property does not trigger a re-render. The ref object itself remains the same across all renders.

Implementation details

useRef is implemented as a simple object with a current property:
// Simplified implementation
function useRef(initialValue) {
  const hook = mountWorkInProgressHook();
  
  if (hook.memoizedState === null) {
    const ref = { current: initialValue };
    hook.memoizedState = ref;
  }
  
  return hook.memoizedState;
}
1

Create ref object

On the first render, React creates an object with a current property set to the initial value.
2

Store in hook node

The ref object is stored in the hook node’s memoizedState.
3

Return same object

On every re-render, React returns the exact same ref object from the hook node.
4

Mutations don't re-render

Mutating ref.current doesn’t update the hook node or schedule a re-render.

Common use cases

const inputRef = useRef(null);

useEffect(() => {
  inputRef.current.focus();
}, []);

return <input ref={inputRef} />;
Access DOM nodes directly without causing re-renders.

useCallback and useMemo memoization

The memoization hooks useCallback and useMemo prevent expensive recalculations by caching results based on dependencies.

useCallback

useCallback returns a memoized version of a callback function that only changes when dependencies change.
// Simplified implementation
function useCallback(callback, deps) {
  const hook = mountWorkInProgressHook();
  const prevDeps = hook.memoizedState?.[1];
  
  if (prevDeps !== null && areHookInputsEqual(deps, prevDeps)) {
    // Dependencies haven't changed, return cached callback
    return hook.memoizedState[0];
  }
  
  // Dependencies changed, cache new callback
  hook.memoizedState = [callback, deps];
  return callback;
}
1

Store callback and deps

On the first render, React stores both the callback and dependencies in the hook node.
2

Compare dependencies

On re-renders, React compares the new dependencies with the cached dependencies.
3

Return cached or new

If dependencies haven’t changed, return the cached callback. Otherwise, cache and return the new callback.
useCallback is particularly useful for preventing child components from re-rendering when passed as props, especially with React.memo.

useMemo

useMemo memoizes the result of an expensive computation:
// Simplified implementation
function useMemo(compute, deps) {
  const hook = mountWorkInProgressHook();
  const prevDeps = hook.memoizedState?.[1];
  
  if (prevDeps !== null && areHookInputsEqual(deps, prevDeps)) {
    // Dependencies haven't changed, return cached value
    return hook.memoizedState[0];
  }
  
  // Dependencies changed, recompute and cache
  const value = compute();
  hook.memoizedState = [value, deps];
  return value;
}
const handleClick = useCallback(() => {
  console.log(count);
}, [count]);
Memoizes the function itself. Equivalent to useMemo(() => fn, deps).

When to use memoization

1

Expensive computations

Use useMemo for computations that take significant time and are called frequently with the same inputs.
2

Referential equality

Use useCallback and useMemo when passing objects or functions as dependencies to other hooks, or as props to memoized child components.
3

Avoid premature optimization

Don’t use memoization everywhere. It adds memory overhead and complexity. Profile first, then optimize.
Remember: memoization trades memory for computation time. The cached values and functions must be stored, and the dependency comparison itself has a cost.

Custom hook composition

Custom hooks allow you to compose built-in hooks into reusable logic. They follow the same implementation rules as built-in hooks.
function useLocalStorage(key, initialValue) {
  // All built-in hooks are called in the same order every render
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });
  
  const setValue = useCallback((value) => {
    try {
      setStoredValue(value);
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error(error);
    }
  }, [key]);
  
  return [storedValue, setValue];
}
Custom hooks must start with “use” so React’s linter can verify that Rules of Hooks are followed. Under the hood, they’re just functions that call other hooks.

Build docs developers (and LLMs) love