- 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.
useState()
call graph is illustrated as below: 

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 aftersetState()
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;
}

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.

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>
</>
);
}

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.

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.


4. Pending Update Queue and Lane Priority
There are three types of pending update queues:
memoizedState.queue.interleaved
: Stores updates that occur outside the render phase.memoizedState.queue.pending
: Stores updates that will be processed in the nextupdateReducer()
call.memoizedState.baseQueue
: Stores updates that were skipped due to lower priority during the current processing.

renderRootSync()
or renderRootConcurrent()
invokes prepareFreshStack()
that in turn invokes finishQueueingConcurrentUpdates()
to merge the updates in the interleaved
into the pending
queue. 
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. 
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>
</>);
}

5. Applying State Changes
When a
setState()
method is invoked, if thememoizedState.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.

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.