First of all, if you are looking for a conceptual explanation of how hooks work and how they know what component instance they are tied to then see the following:
The purpose of this question (if I understand the intent of the question correctly) is to get deeper into the actual implementation details of how React knows which component instance to re-render when state changes via a setter returned by the useState
hook. Because this is going to delve into React implementation details, it is certain to gradually become less accurate as the React implementation evolves over time. When quoting portions of the React code, I will remove lines that I feel obfuscate the most relevant aspects for answering this question.
The first step in understanding how this works is to find the relevant code within React. I will focus on three main points:
- the code that executes the rendering logic for a component instance (i.e. for a function component, the code that executes the component's function)
- the
useState
code
- the code triggered by calling the setter returned by
useState
Part 1 How does React know the component instance that called useState
?
One way to find the React code that executes the rendering logic is to throw an error from the render function. The following modification of the question's CodeSandbox provides an easy way to trigger that error:
This provides us with the following stack trace:
Uncaught Error: Error in child render
at Child (index.js? [sm]:24)
at renderWithHooks (react-dom.development.js:15108)
at updateFunctionComponent (react-dom.development.js:16925)
at beginWork$1 (react-dom.development.js:18498)
at HTMLUnknownElement.callCallback (react-dom.development.js:347)
at Object.invokeGuardedCallbackDev (react-dom.development.js:397)
at invokeGuardedCallback (react-dom.development.js:454)
at beginWork$$1 (react-dom.development.js:23217)
at performUnitOfWork (react-dom.development.js:22208)
at workLoopSync (react-dom.development.js:22185)
at renderRoot (react-dom.development.js:21878)
at runRootCallback (react-dom.development.js:21554)
at eval (react-dom.development.js:11353)
at unstable_runWithPriority (scheduler.development.js:643)
at runWithPriority$2 (react-dom.development.js:11305)
at flushSyncCallbackQueueImpl (react-dom.development.js:11349)
at flushSyncCallbackQueue (react-dom.development.js:11338)
at discreteUpdates$1 (react-dom.development.js:21677)
at discreteUpdates (react-dom.development.js:2359)
at dispatchDiscreteEvent (react-dom.development.js:5979)
So first I will focus on renderWithHooks
. This resides within ReactFiberHooks. If you want to explore more of the path to this point, the key points higher in the stack trace are the beginWork and updateFunctionComponent functions which are both in ReactFiberBeginWork.js.
Here is the most relevant code:
currentlyRenderingFiber = workInProgress;
nextCurrentHook = current !== null ? current.memoizedState : null;
ReactCurrentDispatcher.current =
nextCurrentHook === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
let children = Component(props, refOrContext);
currentlyRenderingFiber = null;
currentlyRenderingFiber
represents the component instance being rendered. This is how React knows which component instance a useState
call is related to. No matter how deeply into custom hooks you call useState
, it will still occur within your component's rendering (happening in this line: let children = Component(props, refOrContext);
), so React will still know that it is tied to the currentlyRenderingFiber
set prior to the rendering.
After setting currentlyRenderingFiber
, it also sets the current dispatcher. Notice that the dispatcher is different for the initial mount of a component (HooksDispatcherOnMount
) vs. a re-render of the component (HooksDispatcherOnUpdate
). We'll come back to this aspect in Part 2.
Part 2 What happens in useState
?
In ReactHooks we can find the following:
export function useState<S>(initialState: (() => S) | S) {
const dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
This will get us to the useState
function in ReactFiberHooks. This is mapped differently for initial mount of a component vs. an update (i.e. re-render).
const HooksDispatcherOnMount: Dispatcher = {
useReducer: mountReducer,
useState: mountState,
};
const HooksDispatcherOnUpdate: Dispatcher = {
useReducer: updateReducer,
useState: updateState,
};
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
last: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
const dispatch: Dispatch<
BasicStateAction<S>,
> = (queue.dispatch = (dispatchAction.bind(
null,
// Flow doesn't know this is non-null, but we do.
((currentlyRenderingFiber: any): Fiber),
queue,
): any));
return [hook.memoizedState, dispatch];
}
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
The important part to notice in the mountState
code above is the dispatch
variable. That variable is the setter for your state and gets returned from mountState
at the end: return [hook.memoizedState, dispatch];
. dispatch
is just the dispatchAction
function (also in ReactFiberHooks.js) with some arguments bound to it including currentlyRenderingFiber
and queue
. We'll look at how these come into play in Part 3, but notice that queue.dispatch
points at this same dispatch
function.
useState
delegates to updateReducer
(also in ReactFiberHooks) for the update (re-render) case. I'm intentionally leaving out many of the details of updateReducer
below except to see how it handles returning the same setter as the initial call.
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const hook = updateWorkInProgressHook();
const queue = hook.queue;
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
You can see above that queue.dispatch
is used to return the same setter on re-render.
Part 3 What happens when you call the setter returned by useState
?
Here is the signature for dispatchAction:
function dispatchAction<A>(fiber: Fiber, queue: UpdateQueue<A>, action: A)
Your new state value will be the action
. The fiber
and work queue
will be passed automatically due to the bind
call in mountState
. The fiber
(the same object saved earlier as currentlyRenderingFiber
which represents the component instance) will point at the same component instance that called useState
allowing React to queue up the re-rendering of that specific component when you give it a new state value.
Some additional resources for understanding the React Fiber Reconciler and what fibers are: