DEV Community

Mattia Pispisa
Mattia Pispisa

Posted on • Updated on

React Hooks and Fiber deep diving

Starting from a code example that had a particular bug will be explored the source code of react focusing especially on hooks and fiber nodes.

Introduction

The idea of this article is to navigate the source code of React to understand the logic behind the “magic” of hooks and fibers. The goal is to ( begin to) understand “where is what” in order to understand what React looks like especially in functionality related to hooks and virtual dom.

When i started to explore the source code, I gave myself a goal.

The goal

One of the hooks I created was the useEnhancedReducer. In a nutshell, a starting point to enhance the React useReducer. Since useReducer is available in React from version 16 I built it using this version (Why limit a functionality only in newer version when older are also compatible?).

If you are interested in what the useEnhancedReducer is I, together with the company where I work: MVLabs, made a talk on it on ReactJs Day 2023.

In one of its early versions this hook had a bug. The bug was that every time an action is dispatched the reducer code were called twice (React 16 has no Strict Mode). The fix was fairly immediate but why there was this problem?

So a great prompt for exploring React’s code is to find the lines that led to this problem.

If you have any doubts about how to test the code in, please see Appendix A.

Let’s begin!

Looking the react source code we can see that the repository is diveded into countless packages. The most interesting ones are: react, react-reconciler, react-dom, shared.

To begin with, I have taken the simplest hook: the useState (but you will see we are not far from the useReducer and hence the useEnhancedReducer) with the simplest possible code:

    function MyReactComponentExample() {
      const [state, setState] = useState(0)

      const increment = () => {
        setState((prev) => prev + 1)
      }

      return ...
    }
Enter fullscreen mode Exit fullscreen mode

Before executing the code, let us first look statically at the react code. Searching “hooks” inside react package we find the ReactHooks.js file and the following code.

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

The other hooks are very similar, all call resolveDispatcher and resolveDispatcher return ReactCurrentDispatcher.current.

    export function useReducer<S, I, A>(
      reducer: (S, A) => S,
      initialArg: I,
      init?: I => S,
    ): [S, Dispatch<A>] {
      const dispatcher = resolveDispatcher();
      return dispatcher.useReducer(reducer, initialArg, init);
    }

    function resolveDispatcher() {
      const dispatcher = ReactCurrentDispatcher.current;
      invariant(...);
      return dispatcher;
    }
Enter fullscreen mode Exit fullscreen mode

But where is assigned ReactCurrentDispatcher?

This can be discovered “easly” setting a breakpoint in

    setState((prev) => prev + 1)
Enter fullscreen mode Exit fullscreen mode

and navigating the source code. At the first “step into”, the code will immediately enter inside the react-dom.development.js file. Looking for the same lines of code in the source code, however, we will see that this is written inside the react-reconciler package. Looking on react package there isn’t a reference on react-dom or react-reconciler (excluding tests). How can this be possible?

Here is where shared comes into plays. There is (almost) no coupling between packages. A package share an object with the shared package and other package can use (fill) objects taking them from shared so we know how react can deal with those two. On the other hand for the link between react-dom and rect-reconciler a good article that explain that is: custom react render (In short, react-dom “implements” a react-reconciler configuration but also take a look at the article!).

react-reconciler

Exploring the file ReactFiberHooks.js inside react-reconciler we might notice the Dispatcher definition (the same type of ReactCurrentDispatcher.current we just encountered) and the objects that implement it. There are many of them, at least one for each state in the lifecycle of a React component!

    export type Dispatcher = {
      ...
      useState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>],
      ...
    };

    ...

    const HooksDispatcherOnMount: Dispatcher = {
      readContext,
      ...
      useState: mountState,
      ...
    };

    const HooksDispatcherOnUpdate: Dispatcher = {
      readContext,
      ...
      useState: updateState,
      ...
    };
Enter fullscreen mode Exit fullscreen mode

To recap: React uses ReactCurrentDispatcher.current which is shared with react-reconciler using shared package. ReactCurrentDispatcher.current is assigned with a different object based on the state of the react component.

Why inside react-reconciler?

React kept in memory a representation of a UI: The virtual Dom. From React docs:

The virtual DOM (VDOM) is a programming concept where an ideal, or “virtual”, representation of a UI is kept in memory and synced with the “real” DOM by a library such as ReactDOM. This process is called reconciliation.

React, however, also uses internal objects called “fibers” to hold additional information about the component tree. They may also be considered a part of “virtual DOM” implementation in React.

What are the additional information about the component? Hooks (and more)!

Back to code

Back to code, the first “step into” of the breakpoint put on the line

    setState((prev) => prev + 1)
Enter fullscreen mode Exit fullscreen mode

had stopped the code within the function dispatchAction.

    function dispatchAction(fiber /* MyReactComponentExample */, queue /* [] */, action /* prev => prev + 1*/) {
      ...
    }
Enter fullscreen mode Exit fullscreen mode

Let us analyse the dispatchAction code on the simplest case, thus with queue parameter empty.

  • An update is defined and action ((prev) => prev + 1) is inserted inside.
    const update: Update<S, A> = {
        ...,
        action, // (prev) => prev + 1
        eagerReducer: null,
        eagerState: null,
        next: (null: any),
      };
Enter fullscreen mode Exit fullscreen mode

A few lines below, since queue is empty, the next state is computed eagerly.

  • The previous state is taken and the action is called:
    const currentState: S = (queue.lastRenderedState: any); // 0
    const eagerState = lastRenderedReducer(currentState, action); // ((0) => 0 +1 )

    // updating references
    update.eagerReducer = lastRenderedReducer;
    update.eagerState = eagerState;
Enter fullscreen mode Exit fullscreen mode

If the new status is the same as the previous one there is no need to scheduling an update. Else, is necessary to schedule an update which can lead to a re-render and commit phase (this process is not covered in this article).

    if (is(eagerState /* 1 */, currentState /* 0 */)) {
      ...
      return;
    }
    ...

    scheduleUpdateOnFiber(root, fiber, lane);
Enter fullscreen mode Exit fullscreen mode

Ps. guess where is function is defined? shared of course!

How is an hook hooked to a fiber?

Or “How is setState(0) hooked to MyReactComponentExample?”

We have seen how a setState is executed but how is the useState connected to a fiber?

Put a breakpoint on the instruction

    const [state,setState] = useState(0)
Enter fullscreen mode Exit fullscreen mode

On the first “step into” the code will reach the mountState function.*

    function mountState<S>(
      initialState: (() => S) | S,
    ): [S, Dispatch<BasicStateAction<S>>] {
      const hook = mountStateImpl(initialState); // see below impl.
      const queue = hook.queue;
      const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
        null,
        currentlyRenderingFiber,
        queue,
      ): any);
      queue.dispatch = dispatch;
      return [hook.memoizedState, dispatch];
    }

    // React 18, in React 16 was a single function
    function mountStateImpl<S>(initialState: (() => S) | S): Hook {
      const hook = mountWorkInProgressHook(); // see below impl.
      ...
      hook.memoizedState = hook.baseState = initialState;
      const queue: UpdateQueue<S, BasicStateAction<S>> = {
        pending: null,
        lanes: NoLanes,
        dispatch: null,
        lastRenderedReducer: basicStateReducer,
        lastRenderedState: (initialState: any),
      };
      hook.queue = queue;
      return hook;
    }

    function mountWorkInProgressHook(): Hook {
      const hook: Hook = {
        memoizedState: null,

        baseState: null,
        baseQueue: null,
        queue: null,

        next: null,
      };

      if (workInProgressHook === null) {
        // This is the first hook in the list
        currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
      } else {
        // Append to the end of the list
        workInProgressHook = workInProgressHook.next = hook;
      }
      return workInProgressHook;
    }
Enter fullscreen mode Exit fullscreen mode

Thus:

  1. A fiber stores an hook within the memoizedState key.

  2. An hook has a reference to the next hook (next key).

    if (workInProgressHook === null) {
        currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
    } else {
        workInProgressHook = workInProgressHook.next = hook;
    }
Enter fullscreen mode Exit fullscreen mode

Each hook has a queue where it contains the pending action (pending), the currently rendered state (lastRenderedState) and more data.

    const queue: UpdateQueue<S, BasicStateAction<S>> = {
      pending: null,
      lastRenderedState: (initialState: any),
      ...
    };
Enter fullscreen mode Exit fullscreen mode

Back to setState

Now that we have seen how an hook is initialized and hooked to a fiber, we can return to the execution of the instruction increment.

    const increment = () => {
        setState((prev) => prev + 1)
    }
Enter fullscreen mode Exit fullscreen mode

The fiber will look like:

    fiber: FiberNode {
      child: ...
      memoizedState: {
        ...
        next: null,
        queue: {
          ...
          lastRenderedState: 0,
          pending: {
            action: prev => prev + 1,
            ...
          }
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

What if the code was this instead

    ...
    const [state,setState] = useState(0)
    const [secondState,setSecondState] = useState("0")

    const increment = () => {
        setState((prev) => prev + 1)
        setSecondState((prev) => prev + "2")
    }
Enter fullscreen mode Exit fullscreen mode

Inside the dispatchAction of setState((prev) => prev + 1):

    fiber: FiberNode {
      child: ...
      memoizedState: { // "A fiber stores an hook within the memoizedState key"
        ...
        next: { // "An hook has a reference to the next hook (next key)"
          ...,
          lastRenderedState: "0",
          pending: null,
        },
        queue: { // now the queue is no more empty
          ...
          lastRenderedState: 0,
          pending: {
            action: prev => prev + 1,
            ...
          }
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

Inside the dispatchAction of setSecondState ((prev) => prev + “2”):

    fiber: FiberNode {
      child: ...
      memoizedState: {
        ...
        next: {
          ...,
          lastRenderedState: "0",
          pending: { // setSecondState((prev) => prev + "2") in pending
            action: prev => prev + "2",
            ...
          }
        },
        queue: {
          ...
          lastRenderedState: 0,
          pending: {
            action: prev => prev + 1,
            eagerState: 1, // first hook eager calculatio 
            ...
          }
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

The fiber contains the list of all hooks in order of definition and inside dispatchAction of setSecondState we have the eagerState of the first setState and the action of setSecondState in pending.

As we can see this function is not only called by the useState but also by the useReducer. In fact, the useState is nothing more than a simplified useReducer!
Ps. As of React 18 the function “dispatchAction” is no longer shared between useState and useReducer but they also remain very similar in creation and update as we will see later on this article.

Where is the status actually calculated?

Early we saw just the eager computation it is now time to see how the actual computation works.

On the next render, re-executing the function const [state,setState] = useState(0) the function that will be called is updateState ( updateState calls updateReducer so useState is just a simplified useReducer). In this function all updates are calculated and the newState is determined. In this way after a setState the state have the value updated.

    function updateReducer<S, I, A>(
      reducer: (S, A) => S,
      initialArg: I,
      init?: I => S,
    ): [S, Dispatch<A>] {
      const hook = updateWorkInProgressHook();
      ...

      if (baseQueue !== null) {
        const first = baseQueue.next;
        let newState = current.baseState;

        let newBaseState = null;
        let newBaseQueueFirst = null;
        let newBaseQueueLast = null;
        let update = first;
        do { // for every update
          const updateLane = update.lane;
          if (!isSubsetOfLanes(renderLanes, updateLane)) {
           ...
          } else {
            ...
            // ---> Process this update <---
            if (update.eagerReducer === reducer) {
              newState = ((update.eagerState: any): S);
            } else {
              const action = update.action;
              newState = reducer(newState, action); // (prev) => prev + 1
            }
            // -----------------------------
          }
          update = update.next;
        } while (update !== null && update !== first);


        if (!is(newState, hook.memoizedState)) {
          markWorkInProgressReceivedUpdate();
        }

        ...
      }

      const dispatch: Dispatch<A> = (queue.dispatch: any);
      return [hook.memoizedState, dispatch];
    }
Enter fullscreen mode Exit fullscreen mode

Now we have all the elements to understand why the dispatched action call the reducer function twice. The useReducer inside dispatchAction immediately calculates the next value (executing the reducer function) and updates update.eagerReducer and update.eagerState with the new values. In the first version of the useEnhancedReducer at the re-render the reducer function was not cached (for example with a useCallback ) so is always recreated at every re-render and even if the function is the same the pointer differs from the previous.

So inside the updateReducer function the reducer will be called a second time.

    if (update.eagerReducer === reducer) { // --> false 
        newState = ((update.eagerState: any): S);
    } else {
        const action = update.action;
        newState = reducer(newState, action); // --> executing for the second time
        // the first time was inside dispatchAction on eager computation
    }
Enter fullscreen mode Exit fullscreen mode

Ps. in React 18 this behavior has changed, the useReducer no longer pre-computes the new state (no more eagerReducer). Moreover bugs due to non-pure functions like this will be highlighted by strict mode.

(The pull request that removes the new state pre-computation, together with an explanation, can be found here: 22445)

Conclusion

In conclusion, starting with the goal of understanding why the useEnhancedReducer was called twice we discovered:

  • How react, shared, react-dom, and react-reconciler communicate;
  • How hooks are defined, hooked to fibers, and what the useState and useReducer looks like;
  • How hooks execute actions and update their state.

A schematic of the analyzed code is shown next

recap

I hope this article has shown something new, maybe replacing what seem like “the magic of react” with real code and that will make it easier for you to understand and explore react.

If you enjoyed this article and want more content on React, you might be interested in the useEnhancedReducer, shown with MVLabs at ReactJs Day 2023.

Appendix A

How the source code can be debugged?

A method that i used to quickly write code, test it and debug source code is to:

  1. Create a CodeSandbox;

  2. Open the developer console onto the source tab (left circle in the image);

  3. Discover the source code (if you will use CodeSandbox the source code path will be similar to the one in the picture), add a breakpoint on code and step into the source.

— left-center, circled in blue, where breakpoint are added — right, circled in blue, all the breakpoints added and buttons to navigate the callStack.

Top comments (1)

Collapse
 
jm__solo profile image
Juan Oliú

Very good job, this post was very helpful to me 💯