Published on

How `useState( )` Hook Works Internally in React 18

Introduction

When you call setState(), the component's state isn't updated immediately. Instead, React creates an update object and places it into a queue within the fiber associated with the component.
React then schedules the component to re-render by calling scheduleUpdateOnFiber(). During the re-rendering process, React invokes the renderWithHooks() function, which manages the lifecycle of hooks, including useState. Depending on whether the hook is being used for the first time or during a subsequent render, React will either mount or update the hook accordingly.

The useState() call graph is illustrated as below: useState-call-graph
The fiber's memoizedState structure are depicted as follows fiber-memoizedState-with-pending-updates

Rules of Hooks

Before diving deeper, it's essential to understand some key rules that govern how hooks, including useState, should be used. In this blog, I will explain the reasoning behind these rules:

  • Hooks must be called at the top level
    Hooks should always be called at the top level of a React function component or a custom hook. They should not be called conditionally, inside loops, or within nested functions. This ensures hooks are invoked in the same order each time a component renders, which is crucial for React to correctly preserve the state across multiple renders.

  • Avoid Directly Calling a React Component Function
    Directly calling a React component function can lead to unexpected behavior because it bypasses React's rendering lifecycle, including state management and effect cleanup. Components should be used in JSX or via React.createElement().

  • State Updates During Render Are an Anti-Pattern
    State updates triggered during the render phase can lead to unintended side effects and may cause React to enter an infinite loop. Thus, such practices are generally discouraged.

  • Asynchronous Nature of setState()
    setState() is asynchronous, meaning it schedules an update and then exits. The actual state change doesn’t happen immediately during the current function execution. Any code relying on the updated state running right after setState() will encounter the old state value until the next render.

  • React Batches setState() Calls
    To optimize performance, React batches multiple setState() calls into a single update. This means that multiple setState() calls during the same synchronous execution cycle will result in a single re-render at the end of the event handling, ensuring the component only re-renders once per batch.

1. Three Implementations of useState

Each hook in React is implemented in three forms:

  • mountFoo (HooksDispatcherOnMount): Initializes the hook during the component's initial render.
  • updateFoo (HooksDispatcherOnUpdate): Updates the hook's state or re-applies effects based on the changes since the last render.
  • renderFoo (HooksDispatcherOnRerender): Handles re-renders that occur without a change in state, props, or context. Typically due to a parent component's re-render, and for double invoking components in Dev Strict Mode. It does not update the hook state but checks for consistency, ensuring the hooks' identity and order remain stable across renders.

React handles hooks like useState and useEffect through a dispatcher mechanism, which ensures that the correct implementation of a hook is called based on the rendering phase—whether the component is being rendered for the first time (mount) or during subsequent renders (update).

When a hook like useState or useEffect is invoked, it internally calls resolveDispatcher(), which accesses ReactCurrentDispatcher to retrieve and execute the correct hook implementation. For example, useState() is implemented as follows:

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

https://github.com/facebook/react/blob/v18.3.1/packages/react/src/ReactHooks.js#L80

The renderWithHooks() function is responsible for rendering components that use hooks. It configures ReactCurrentDispatcher to reference either the mountFoo or updateFoo implementations, depending on whether the component is being rendered for the first time or has already been rendered.

You may refer the function renderWithHooks() code from here https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberHooks.new.js#L415

if (__DEV__) {
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
// This dispatcher handles an edge case where a component is updating,
// but no stateful hooks have been used.
// We want to match the production code behavior (which will use HooksDispatcherOnMount),
// but with the extra DEV validation to ensure hooks ordering hasn't changed.
// This dispatcher does that.
ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
}
} else {
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
}
...

// Check if there was a render phase update
if (didScheduleRenderPhaseUpdateDuringThisPass) {
  ...
   ReactCurrentDispatcher.current = __DEV__
          ? HooksDispatcherOnRerenderInDEV
        : HooksDispatcherOnRerender;
}

renderWithHooks

2. State Initialization

During the initial render of a component, when useState() is called, resolveDispatcher() returns the mountState() method. mountState() internally invokes mountWorkInProgressHook(), which creates a hook object stored as memoizedState in the corresponding fiber. This allows React to maintain the state across renders.

mountState

The first hook used in a function component is stored in the memoizedState property of the fiber. Subsequent hooks are stored in a linked-list manner, where the second is stored in memoizedState.next, the third in memoizedState.next.next, and so on.

mountState() returns a state variable along with a bound version of the dispatchSetState() function. Consequently, the setter function provided by the useState hook is effectively a bound version of dispatchSetState().

In the example below, the Counter component demonstrates how useState hooks are organized in the fiber's memoizedState:

export function Counter() {
  const [count1, setCount1] = useState(10);
  const [count2, setCount2] = useState(100);
  const [count3, setCount3] = useState(1000);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    setCount1((prev) => prev + 1);
    setCount1((prev) => prev + 2);
    setCount2((prev) => prev + 200);

    startTransition(() => {
      setCount3((prev) => prev + 3000);
    });
  };

  return (
    <>
      <h2> Counter1: {count1}</h2>
      <h2> Counter2: {count2}</h2>
      <h2> Counter3: {count3}</h2>
      <button onClick={handleClick}>Increase</button>
    </>
  );
}

fiber-memoizedState-structure

If hooks were declared within loops or conditional structures, the hook list in the work-in-progress fiber might differ from the current fiber across renders. This mismatch can cause the hook states to become misaligned between the current and work-in-progress fibers, which is why hooks should not be declared inside loops or conditions.

hook-work-in-progress

3. Queueing Updates with Setter Function

When the setState() method (a bound version of dispatchSetState()) is invoked, it creates an update object containing the update details, such as the action (callback) and update priority. dispatchSetState() then invokes enqueueConcurrentHookUpdate() to add this update object to the fiber's update queue.

If there is no existing update object in the fiber, this new update object is stored as fiber.memoizedState.queue.interleaved. If the setState() is invoked multiple times, subsequent update objects are stored in a circular linked list. This structure optimizes performance by enabling React to efficiently manage and add new updates.

The circular linked list design means that the last update object points back to the initial node (fiber.memoizedState.queue.interleaved). This setup allows React to quickly append new updates and provides easy access to the last and first nodes in the list, enhancing the efficiency of state management.

In the example above, the memoizedState and updates objects are depicted as follows: fiber-memoizedState-with-pending-updates
fiber-memoizedState-screenshot

4. Pending Update Queue and Lane Priority

There are three types of pending update queues:

  1. memoizedState.queue.interleaved: Stores updates that occur outside the render phase.
  2. memoizedState.queue.pending: Stores updates that will be processed in the next updateReducer() call.
  3. memoizedState.baseQueue: Stores updates that were skipped due to lower priority during the current processing.
pending-update-queue
During the render phase, renderRootSync() or renderRootConcurrent() invokes prepareFreshStack() that in turn invokes finishQueueingConcurrentUpdates() to merge the updates in the interleaved into the pending queue. finishQueueingConcurrentUpdates-screenshot
Next, the updateReducer() will be invoked that merges the updates in the pending queue into the baseQueue and then traverses the baseQueue, executing the callbacks for the updates that match the current update priority.
Updates that do not meet the required priority are stored back in the baseQueue, awaiting the next rendering cycle to be processed. updateReducer-screenshot

State Updates During Render

When a state update is triggered during the render phase, React cannot immediately re-render the component because it is already in the middle of the rendering process. In this scenario, dispatchSetState() invokes enqueueRenderPhaseUpdate() to queue the update object into memoizedState.queue.pending. React processes these pending updates after the current render is completed, which may lead to another render to apply the state changes.

However, triggering state updates during the render phase is generally considered an anti-pattern, as it can lead to unintended side effects and potentially cause React to enter an infinite loop.

The state updates during the render phase is illustrated as below:

export function Counter() {
  const [count1, setCount1] = useState(1);
  const [count2, setCount2] = useState(10);

  // This state update is triggered during the render phase
  if(count2 === 20) {
    setCount2(prev => prev + 20);

    // eslint-disable-next-line no-debugger
    debugger;
  }

  const handleClick = () => {
    setCount1((prev) => {
      return prev + 1;
    });

    setCount2((prev) => {
      return prev + 10;
    });
  };

  return (<>
    <h2> Counter1: {count1}</h2>
    <h2> Counter2: {count2}</h2>
    <button onClick={handleClick}>Increase</button>
  </>);
}

state-update-during-render

5. Applying State Changes

  • When a setState() method is invoked, if the memoizedState.queue.interleaved queue is empty, React can compute the next state immediately. If the new state is the same as the current state, React skips scheduling a re-render, allowing an early bailout.

  • Otherwise, React creates an update object representing the update information, stores it in the fiber, and schedules a re-render.

During the re-render:

(a) React invokes finishQueueingConcurrentUpdates() to merge updates from the interleaved queue into the pending queue.

(b) renderWithHooks() is called, configuring ReactCurrentDispatcher to point to the updateState() implementations.

(c) renderWithHooks() then invokes the component function to create a new fiber. During the component function's execution, useState() is invoked, which now points to the updateState() implementation. Internally, updateState() calls updateReducer() to process the pending updates.

(d) updateReducer() executes callbacks for the updates that match the current update priority. Updates that do not meet the required priority are stored back in the base queue, awaiting the next rendering cycle to be processed.

The call graph for render process is illustrated as below: updateState-call-graph

Conclusion

Understanding how useState works internally in React 18 offers a deeper insight into the efficiency and complexity of React's state management system. By adhering to the rules of hooks and leveraging React's internal mechanisms, developers can create performant and maintainable React applications. The intricate process behind useState highlights the power and flexibility of hooks in modern React development.