In-depth Understanding of React: The Principles of Hooks (Part 1)

Time: Column:Mobile & Frontend views:195

Hooks are a hallmark feature of React, representing a significant breakthrough in React's embrace of functional programming. By using hooks, we can allow functional components to maintain their own state. I believe everyone has already experienced the benefits of hooks in their daily development. This article will explore how hooks work and how to use them elegantly. By the end of this article, you should have a deeper understanding of the following questions:

  • Where is the state of useState stored?

  • How does a functional component maintain multiple hooks?

  • How do the dependencies of useEffect work?

  • Many more topics...

Without further ado, let’s get started!

useState

When our component enters the render phase, renderWithHooks is called, and this is where we invoke our defined component. The various hooks written in the function will also be called. In previous articles, we mentioned that React sets the reference object for hooks to be the actual reference object. During initialization (i.e., the first call to renderWithHooks), it sets the reference object to HooksDispatcherOnMountInDEV, which contains various hooks objects initialized as shown below:

HooksDispatcherOnMountInDEV = {
      readContext: function (context) { ... },
      useCallback: function (callback, deps) { ... },
      useContext: function (context) { ... },
      useEffect: function (create, deps) { ... },
      useImperativeHandle: function (ref, create, deps) { ... },
      ...
      useState: function (initialState) { ... },
      ...
};

Initialization

Let’s first look at how useState is implemented. The code executed during initialization is as follows:

function (initialState) {
    currentHookNameInDev = "useState";
    var prevDispatcher = ReactCurrentDispatcher$1.current;
    ReactCurrentDispatcher$1.current =
      InvalidNestedHooksDispatcherOnMountInDEV;
    try {
      return mountState(initialState);
    } finally {
      ReactCurrentDispatcher$1.current = prevDispatcher;
    }
}

This directly returns the value corresponding to mountState.

function mountState(initialState) {
    // 1 Create hook object
    var hook = mountWorkInProgressHook();
    if (typeof initialState === "function") {
      // $FlowFixMe: Flow doesn't like mixed types
      initialState = initialState();
    }
   
    hook.memoizedState = hook.baseState = initialState;
    // Create an update queue
    var queue = {
      pending: null,
      interleaved: null,
      lanes: NoLanes,
      dispatch: null,
      lastRenderedReducer: basicStateReducer,
      lastRenderedState: initialState,
    };
    hook.queue = queue;
    // Return dispatchSetState
    var dispatch = (queue.dispatch = dispatchSetState.bind(
      null,
      currentlyRenderingFiber$1, // This is the fiber object corresponding to the component
      queue
    ));
    return [hook.memoizedState, dispatch];
}

The steps during initialization include:

  1. Creating a hook object.

  2. Returning the initial value and the dispatch function, linking it to the current fiber.

Next, let's look at what a hook looks like:

function mountWorkInProgressHook() {
    var hook = {
      memoizedState: null, // Current hook state
      baseState: null, // Previous state
      baseQueue: null, // Previous update queue
      queue: null, // Current hook's update queue
      next: null, // Next hook
    };
    if (workInProgressHook === null) { // Indicates the first hook
      currentlyRenderingFiber$1.memoizedState = workInProgressHook = hook;
    } else { // Add to the next pointer of the previous hook
      workInProgressHook = workInProgressHook.next = hook;
    }
    return workInProgressHook;
}

We can see that the hook object has five basic properties, which I will explain in order:

  • memoizedState: Represents the current state of the hook and is the value returned to the user.

  • baseState: Represents the basic state for the current update, existing only when a higher priority update task causes a particular update to be skipped.

  • baseQueue: Similarly represents the basic queue for the current update and is used in conjunction with baseState.

  • queue: Represents the update queue for the current update.

  • next: Represents the next hook object.

From this, we can see that functional components use a singly linked list to maintain the hooks defined in functions. For example, if we define the following states:

import React, { useState } from 'react';

const ExampleComponent = () => {
  // Define state Hook a
  const [a, setA] = useState('Initial Value A');

  // Define state Hook b
  const [b, setB] = useState(0);

  // Define state Hook c
  const [c, setC] = useState({ key: 'Initial Value C' });

  // Define state Hook d
  const [d, setD] = useState(['Initial Value D']);

  return (
    <div>
      ...
    </div>
  );
};

export default ExampleComponent;

The fiber object corresponding to ExampleComponent will have such a hook linked list.

In-depth Understanding of React: The Principles of Hooks (Part 1)

Subsequently, each useState type hook will have an empty update queue linked list during initialization, which is consistent with the updateQueue structure mentioned earlier in our series. Once we create the hook object, we assign the user-provided initial value to the hook object’s memoizedState and return dispatchSetState. As for what dispatchSetState is, we can analyze it later. Through the above analysis, we can conclude that the state of functional components is actually stored in the hook objects of the fiber.

dispatchSetState

After the initialization is complete, users can typically trigger updates through certain interactions, which will call dispatchSetState, or the user-defined setXXX. Generally, it performs the following tasks:

  1. Creates an update priority.

  2. Creates an update object.

  3. Adds the update object to the current hook object's update queue, forming a circular linked list.

The process of forming this linked list is detailed in the column "In-depth Understanding of React: Priorities (Part 1)," so I won’t elaborate further here.

  1. Labels the ancestor nodes from the point where the update occurs.

  2. Schedules the update.

This is the process triggered by dispatchSetState that then enters the render phase of the update workflow.

Updates

During the update, the render phase will also execute renderWithHooks to generate the latest React element. At this point, the list of hooks being called is not the one from initialization but rather the one from the update, as shown below:

HooksDispatcherOnUpdateInDEV = {
  readContext: function (context) { ... },
  useCallback: function (callback, deps) { ... },
  useContext: function (context) { ... },
  useEffect: function (create, deps) { ... },
  useImperativeHandle: function (ref, create, deps) { ... },
  ...
  useState: function (initialState) { ... },
  ...
};

The implementation of useState during the update is as follows:

function (initialState) {
    ...
    try {
      return updateState(initialState);
    } finally {
      ReactCurrentDispatcher$1.current = prevDispatcher;
    }
}

updateState directly calls updateReducer. Let's take a look at the implementation of updateReducer:

function updateReducer(reducer, initialArg, init) {
    var hook = updateWorkInProgressHook(); // Get the current work-in-progress hook object.
    var queue = hook.queue; // Get the update queue of this hook object.
    queue.lastRenderedReducer = reducer; // Default calculation logic, used when setXX(fn) is passed.
    var current = currentHook; // Global variable that saves the current hook object.
    // When the user calls setXX(), the update object is actually stored on the current tree.
    var baseQueue = current.baseQueue; // Initialized to null; this is the priority queue from the last update.
    var pendingQueue = queue.pending; // Update queue.

    if (pendingQueue !== null) {
      ...
      current.baseQueue = baseQueue = pendingQueue;
      queue.pending = null;
    }

    if (baseQueue !== null) { // We enter here to perform state calculations.
      var first = baseQueue.next;
      var newState = current.baseState;
      var newBaseState = null;
      var newBaseQueueFirst = null;
      var newBaseQueueLast = null;
      var update = first;

      do {
        var updateLane = update.lane;

        if (!isSubsetOfLanes(renderLanes, updateLane)) { // Skip if the priority doesn't match.
          ...
        } else {
          ...
          if (update.hasEagerState) {
            newState = update.eagerState; // If it's a simple type like setXX('xx'), we take this path.
          } else {
            var action = update.action;
            newState = reducer(newState, action); // If it's a function like setXX(fn), we take this path.
          }
        }

        update = update.next;
      } while (update !== null && update !== first);

      ...

      hook.memoizedState = newState;
      hook.baseState = newBaseState;
      hook.baseQueue = newBaseQueueLast;
      queue.lastRenderedState = newState;
    } 
    ...

    var dispatch = queue.dispatch;
    return [hook.memoizedState, dispatch];
}

As seen above, the core of the update process is to traverse the update queue to compute the new state, obtain the latest state, and return it to the component for consumption. This is the only way to create a React element based on the latest state.

The core implementation of useState can be represented in pseudocode as follows:

const fiber = {
  state: stateInformation
}

function useState(){
  return [fiber.state, setState];
}

function setState(xxx){
  fiber.state = xxx;
  schedule();
}

function Component(){
  const [, setState] = useState();
  return interface;
}

To visualize this, see the image below:

In-depth Understanding of React: The Principles of Hooks (Part 1)


useEffect

The implementation of all hooks differs between updates and initialization, so let's take a look at each separately.

Mount

In the mount phase, the implementation of useEffect looks like this:

function (create, deps) { // create is the user-defined function, deps are the dependencies
   return mountEffect(create, deps);
}

This then executes mountEffectImpl(Passive | PassiveStatic, Passive$1, create, deps), where the Passive side effect is passed in during this process.

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
    var hook = mountWorkInProgressHook(); // Create hook object
    var nextDeps = deps === undefined ? null : deps;
    currentlyRenderingFiber$1.flags |= fiberFlags;
    hook.memoizedState = pushEffect(HasEffect | hookFlags, create, undefined, nextDeps);
}

We've previously analyzed mountWorkInProgressHook, which is responsible for creating a hook object. Importantly, it also tags the current fiber object with a Passive effect, allowing it to be consumed during the commit phase. Thus, we can conclude that whenever a functional component uses hooks like useEffect, it will be tagged with a Passive side effect.

The most important step is the third one, which encapsulates the effect. Let's take a look at the implementation of pushEffect:

function pushEffect(tag, create, destroy, deps) { 
    var effect = {
      tag: tag, // tag contains the constant for the Passive side effect
      create: create, // A function
      destroy: destroy, // Currently undefined
      deps: deps, // Dependencies
      next: null // Clearly, this is a linked list
    };
    var componentUpdateQueue = currentlyRenderingFiber$1.updateQueue; // Update queue

    if (componentUpdateQueue === null) { // When it's the first hook, it's null
      componentUpdateQueue = createFunctionComponentUpdateQueue(); // Creates an object like { lastEffect: link, stores: null }
      currentlyRenderingFiber$1.updateQueue = componentUpdateQueue;
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else { // If the queue exists
      var lastEffect = componentUpdateQueue.lastEffect;
      if (lastEffect === null) { // Constructs a circular linked list
        componentUpdateQueue.lastEffect = effect.next = effect;
      } else { 
        var firstEffect = lastEffect.next;
        lastEffect.next = effect;
        effect.next = firstEffect;
        componentUpdateQueue.lastEffect = effect;
      }
    }

    return effect;
}

Through this step, we see that when we declare multiple hooks in a functional component, a circular linked list is formed, which is referenced by the current fiber's updateQueue.

In summary, suppose we write the following useEffect list:

function FunctionComponent(){
    useEffect(...); // 1
    useEffect(...); // 2
    useEffect(...); // 3
    useEffect(...); // 4
    return ...
}

In memory, there will be a structure like the following:

In-depth Understanding of React: The Principles of Hooks (Part 1)

Update

Whether it’s initialization or update, the process involves attaching this side effect's effect list to the newly constructed fiber tree. Thus, the actions taken during the update are similar to those during the mount.

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
    var hook = updateWorkInProgressHook();
    var nextDeps = deps === undefined ? null : deps;
    var destroy = undefined;

    if (currentHook !== null) { // Exists during the update
      var prevEffect = currentHook.memoizedState;
      destroy = prevEffect.destroy;

      if (nextDeps !== null) {
        var prevDeps = prevEffect.deps; // nextDeps actually depends on the latest values
        if (areHookInputsEqual(nextDeps, prevDeps)) { // If they are not equal, new dependencies need to be added to the list
          hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
          return;
        }
      }
    }

    currentlyRenderingFiber$1.flags |= fiberFlags;
    hook.memoizedState = pushEffect(HasEffect | hookFlags, create, destroy, nextDeps);
}

However, during the update, it is necessary to check whether the dependencies are consistent with the previous ones. If they are consistent, it indicates that the dependencies haven't changed, and the corresponding hook function does not need to be executed during this update. This is implemented by passing different tags to the effect. Remember, during initialization, pushEffect was called with HasEffect | hookFlags, whereas if the dependencies remain unchanged, only hookFlags is passed, which simplifies the process of determining whether to execute the corresponding hook function during the commit phase.

Executing Side Effects

Calling the useEffect hook essentially only stores the relevant information on the fiber and tags it; the actual execution of side effects occurs during the commit phase. Therefore, the implementation of useEffect integrates both the render and commit processes. In the article "In-depth Understanding of React: The Commit Phase," we mentioned that useEffect would be executed asynchronously through flushPassiveEffects, so let’s see how this is done, specifically in commitHookEffectListMount:

function commitHookEffectListMount(flags, finishedWork) {
    var updateQueue = finishedWork.updateQueue;
    var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
    
    if (lastEffect !== null) {
      var firstEffect = lastEffect.next;
      var effect = firstEffect;
      do {
        if ((effect.tag & flags) === flags) { // Check if the current tag is the same as before
          var create = effect.create;
          effect.destroy = create();
          ...
        }

        effect = effect.next;
      } while (effect !== firstEffect);
    }
}

The logic is quite clear: essentially, it retrieves the side effect list from the fiber and executes them sequentially. If it encounters unchanged dependencies or hooks like useLayoutEffect, it will skip them. Thus, user-defined functions that meet the conditions will be executed here!


useLayoutEffect

At this point, you may have guessed the implementation principle of useLayoutEffect. Its syntax is identical to that of useEffect; the only difference is the timing of the side effect execution.

The execution timing of useLayoutEffect is during the commit phase's Mutation, which means it runs synchronously after DOM changes, while useEffect starts scheduling before mutation but is executed asynchronously. Therefore, useLayoutEffect occurs later and does not block DOM rendering. If useLayoutEffect contains CPU-intensive calculations, it will block UI rendering.

During the initialization process of useLayoutEffect, the implementation called is the same as for useEffect, but it simply distinguishes itself by using a different side effect tag:

function mountLayoutEffect(create, deps) {
    var fiberFlags = Update;
    {
      fiberFlags |= LayoutStatic;
    }
    return mountEffectImpl(fiberFlags, Layout, create, deps);
}

This allows the commit phase to differentiate which effects should be executed based on their tags. We can represent this in pseudocode as follows:

function commit(){ // Commit phase
   asyncExecute(() => {
      1. Traverse the fiber tree
      2. Retrieve the current fiber tree's updateQueue, which is the side effect list
      3. Traverse the entire list
      4. Skip Layout effects and unchanged dependencies, executing only Passive side effects
   })
    
   // Mutation phase
   Perform various DOM operations
  
   1. Traverse the fiber tree
   2. Retrieve the current fiber tree's updateQueue, which is the side effect list
   3. Traverse the entire list
   4. Skip Passive effects and unchanged dependencies, executing only Layout side effects
   
   ...
}

Final Thoughts

Through this article, we have gained an understanding of the principles behind useEffect, useState, and useLayoutEffect, and we have dissected their implementations from the source code perspective. I believe you will feel more confident during your next interview! Due to space constraints, we will continue exploring more hook principles in the next article!