How do react hooks determine the component that they are for?

I noticed that when I was using react hooks, a child component's state change does not rerender a parent component that had no state change. This is seen by this code sandbox: https://codesandbox.io/s/kmx6nqr4o

Due to the lack of passing the component to the hook as an argument, or as a bind context, I had mistakenly thought that react hooks / state changes simply triggered an entire application rerender, like how mithril works, and what React's Design Principles states:

React walks the tree recursively and calls render functions of the whole updated tree during a single tick.

Instead, it seems that react hooks know which component they are associated to, and thus, the rendering engine knows to only update that single component, and never call render on anything else, opposing what React's Design Principles document said above.

  1. How is the association between hook and component done?

  2. How does this association make it so that react knows to only call render on components whose state changed, and not those without? (in the code sandbox, despite child's state changing, the parent element's render is never called)

  3. How does this association still work when you abstract the usage of useState and setState into custom hook functions? (as the code sandbox does with the setInterval hook)

Seems the answers lie somewhere with this trail resolveDispatcher, ReactCurrentOwner, react-reconciler.


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:

  • in depth article I found after writing this answer
  • Hooks FAQ
  • related StackOverflow question
  • related blog post by Dan Abramov

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:

Edit React hooks parent vs child state

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:

  • Fiber Reconciler portion of https://reactjs.org/docs/codebase-overview.html
  • https://github.com/acdlite/react-fiber-architecture
  • https://blog.ag-grid.com/index.php/2018/11/29/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/