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

Time: Column:Mobile & Frontend views:296

In the previous article, we explored the basic principles of useState, useEffect, and useLayoutEffect, along with their execution processes through source code analysis. In this article, we will continue our exploration of commonly used React hooks.

1. useMemo & useCallback

The principles behind these two hooks are quite similar, so we can introduce them together. Like the hooks discussed earlier, they can be divided into initialization and update scenarios.

Initialization

The initialization of useMemo will call mountMemo.

function mountMemo(nextCreate, deps) {
    var hook = mountWorkInProgressHook(); // Create the current hook object and attach it to the fiber's hook list
    var nextDeps = deps === undefined ? null : deps;
    var nextValue = nextCreate();
    hook.memoizedState = [nextValue, nextDeps];
    return nextValue;
}

mountWorkInProgressHook has been analyzed in the previous article. Most hooks need to call this to create their hook object during initialization, although there are exceptions, such as useContext, which we will discuss later. The first time useMemo is executed, it calls the user-provided function to get the value that needs to be cached, storing both the dependency and the value in the hook's memoizedState.

The initialization of useCallback will call mountCallback.

function mountCallback(callback, deps) {
    var hook = mountWorkInProgressHook();
    var nextDeps = deps === undefined ? null : deps;
    hook.memoizedState = [callback, nextDeps];
    return callback;
}

The only difference is that useCallback directly caches the passed function without calling it for evaluation. After initialization, the corresponding hook information is stored on the fiber node of the component, and the cached function and value are retained in this hook.

Update

When updating, useMemo actually calls updateMemo, which is implemented as follows:

function updateMemo(nextCreate, deps) {
    var hook = updateWorkInProgressHook(); // Create a work-in-progress hook object based on current
    var nextDeps = deps === undefined ? null : deps; // Get the latest dependency value
    var prevState = hook.memoizedState; // The old cached value

    if (prevState !== null) {
        if (nextDeps !== null) {
            var prevDeps = prevState[1];

            if (areHookInputsEqual(nextDeps, prevDeps)) { // Compare the latest dependency value
                return prevState[0]; // If they are the same, return the cached value
            }
        }
    }
    // Dependencies have changed, recalculate
    var nextValue = nextCreate();
    // Store it back in the corresponding hook object
    hook.memoizedState = [nextValue, nextDeps];
    return nextValue;
}

During each update, the dependencies are checked for changes using areHookInputsEqual. This function compares each item in the array to see if it matches the original. If any differences are found, it returns false, leading to recalculation.

function areHookInputsEqual(nextDeps, prevDeps) {
   for (var i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
      if (objectIs(nextDeps[i], prevDeps[i])) { // Check for equality
        continue;
      }
      return false;
   }
   return true;
}

The core principle of caching is that the memoizedState in the work-in-progress hook object directly reuses the original hook object, allowing all related information to be retained. Updates occur only when necessary. The update logic for useCallback is the same as for useMemo, so we won't elaborate further on that here.

2. useRef

Next, let's examine the basic principles of useRef. First, let's review its function: useRef is used to store references to data, which can be basic types, complex types, DOM elements, class component instances, etc. The values stored in useRef remain consistent throughout component updates, making it ideal for persisting data.

Initialization

During initialization, a reference object is created using mountRef.

function mountRef(initialValue) {
    var hook = mountWorkInProgressHook(); // Create a hook object
    {
      var _ref2 = { // Create a ref object
        current: initialValue,
      };
      hook.memoizedState = _ref2; // Store it in the hook's memoizedState
      return _ref2; // Return the ref object
    }
}

The initialization logic is straightforward: a ref object is created and stored in the memoizedState property of the corresponding hook.

Update

function updateRef(initialValue) {
    var hook = updateWorkInProgressHook();
    return hook.memoizedState;
}

Updating a ref is even simpler; it directly returns the original reference. Since the hook's information is based on the old hook, it remains unchanged. Thus, throughout the runtime of React, this reference behaves like a static variable, permanently retaining its value.

DOM Elements & Class Component Instances

In our previous article on the commit phase of React, we analyzed how refs can sometimes store special information, such as DOM elements or class component instances.

const ref = React.useRef();

<h1 id="h1" ref={ref}>hello</h1>

or

<ClassComponent ref={ref}/>

or

<FunctionComponent ref={ref}/>

The process of creating a ref occurs during the render phase. In all these cases, the current component's fiber will be marked with a ref tag. During the commit phase, the relevant information is assigned to the corresponding ref reference to achieve persistent storage.

In the commit phase, commitAttachRef will assign the stateNode property of the fiber to the ref object, which will be the instance object for class components and the DOM element for native elements. For function components, it will be the object returned by useImperativeHandle, which we will explore later.

3. useContext

You may frequently use useContext in your work, as it conveniently allows for state lifting to a higher level, enabling any descendant components to consume the state information. This helps avoid the awkward situation of passing props down multiple layers. Let’s explore how it is implemented!

Before using useContext, we need to create a context, so let’s take a look at what React.createContext() does!

function createContext(defaultValue) {
    var context = { // Creates a context object that looks like this
      $$typeof: REACT_CONTEXT_TYPE,
      _currentValue: defaultValue,
      _currentValue2: defaultValue,
      _threadCount: 0,
      Provider: null,
      Consumer: null,
      _defaultValue: null,
      _globalName: null,
    };
    context.Provider = { // Provider component that provides context
      $$typeof: REACT_PROVIDER_TYPE,
      _context: context,
    };
    
    {
      var Consumer = { // Consumer component that consumes context
        $$typeof: REACT_CONTEXT_TYPE,
        _context: context,
      };
      // Bind some properties to Consumer
      Object.defineProperties(Consumer, {
        Provider: {
          get: function () {
            return context.Provider;
          },
          set: function (_Provider) {
            context.Provider = _Provider;
          },
        },
        ...
        Consumer: {
          get: function () {
            return context.Consumer;
          },
        },
      });
      context.Consumer = Consumer;
    }
    // Return the context
    return context;
}

I have retained the core context creation process, which is relatively easy to understand. Within the context, there are Provider and Consumer, both of which are of ReactElement type and can be directly consumed using JSX. From the logic, we can see that context, Provider, and Consumer are all mutually referenced.

Generally, the process of creating context occurs first, followed by triggering the render phase of Provider, and finally triggering useContext. We know that useContext needs to be executed within renderWithHooks, which occurs during the beginWork process, thus following a top-down order.

Provider

Provider is a type of React element with its own fiber type. When its parent node is reconciled, the corresponding fiber node will also be created, with the tag type being 10.

export const ContextProvider = 10;

When we use Provider, we also inject custom information into it:

<Provider value={{... }}>
  <.../>
</Provider>

This information will be saved in the pendingProps of the Provider type fiber. When reconciling this Provider, it will enter updateContextProvider for processing:

function updateContextProvider(current, workInProgress, renderLanes) {
    var providerType = workInProgress.type; // Context information { _context: context , $$typeof: xxx }
    var context = providerType._context;
    var newProps = workInProgress.pendingProps;
    var newValue = newProps.value; // User-defined value
    pushProvider(workInProgress, context, newValue); 
    ...
    return workInProgress.child;
}

The Provider will have context information since they reference each other.

Understanding React: Principles of Hooks (Part 2)

Here, pushProvider(workInProgress, context, newValue); assigns the user-defined value to _currentValue in the context:

function pushProvider(providerFiber, context, nextValue) {
   ...
   context._currentValue = nextValue;
}

At this point, the provider task is complete, storing the upper state and methods in the shared context. Next, we look at how the lower layers consume this context.

useContext

We can use useContext to consume the upper state. Unlike other hooks, both the initialization and update phases call readContext to retrieve the relevant information:

function readContext(context) {
    var value = context._currentValue; // Directly get the context
    ...
    {
      var contextItem = {
        context: context,
        memoizedValue: value,
        next: null
      };

      if (lastContextDependency === null) {
        // If it is the first useContext
        lastContextDependency = contextItem;
        currentlyRenderingFiber.dependencies = { // Context information is stored in the dependencies property
          lanes: NoLanes,
          firstContext: contextItem
        };
      } else {
        // If multiple, form a unidirectional linked list
        lastContextDependency = lastContextDependency.next = contextItem;
      }
    }
    return value;
}

From the analysis above, we can conclude that useContext does not create a linked list in the fiber's memoizedState, but rather forms one in the dependencies property. For instance, if we use two useContext calls to get upper information:

function App() {
  const context1 = useContext(Context1);
  const context2 = useContext(Context2);

  return (...)
}

The corresponding Fiber structure should look like this.

Understanding React: Principles of Hooks (Part 2)

Since beginWork is top-down, when ReactContext retrieves the state, the value has already been updated to the latest state in the ancestor node. Therefore, the state consumed when using useContext is also the latest.

If an update is triggered from useContext, the triggering setXXX originates from the ancestor node, which means the update will start from the ancestor node, causing the entire subtree of the ancestor component to be re-rendered.

Understanding React: Principles of Hooks (Part 2)

Consumer

In addition to using useContext, we can also consume context via Consumer, as shown below:

import AppContext from 'xxx';

const Consumer = AppContext.Consumer;

function Child() {
  return (
    <Consumer>
      {
        (value) => xxx
      }
    </Consumer>
  );
}

During the render phase, when beginWork reaches a Consumer type node, it triggers updateContextConsumer:

function updateContextConsumer(current, workInProgress, renderLanes) {
    var context = workInProgress.type; // Consumer type fiber stores context information in the type property
    context = context._context;
    var newProps = workInProgress.pendingProps; // Get props
    var render = newProps.children;

    {
      if (typeof render !== 'function') { // Must be a function included by Consumer
        throw new Error();
      }
    }
    
    var newValue = readContext(context); // Still calls readContext
    var newChildren;
    
    newChildren = render(newValue); // Pass the latest state to the lower layers for consumption
     
    reconcileChildren(current, workInProgress, newChildren, renderLanes); // Continue reconciling child nodes
    return workInProgress.child;
}

As we can see, Consumer internally still uses readContext to acquire context information, and the principle is consistent with useContext.

Summary

Through the above analysis, we can draw a conclusion: the fundamental principle of context is to utilize the top-down characteristics of beginWork to first store the state in a shared third party at the upper level. Consequently, the lower nodes can safely consume the state stored in the third party, which is essentially our context.

4. useImperativeHandle

The purpose of the useImperativeHandle hook is widely understood: functional components don’t have instances by default, but this hook allows users to define specific methods to expose for parent components. Let’s explore how it achieves this.

Initialization

During initialization, useImperativeHandle calls mountImperativeHandle:

function mountImperativeHandle(ref, create, deps) { // ref is a reference from the parent component { current:xxx }
    // Essentially calls mountEffectImpl
    var effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : null;
    var fiberFlags = Update;
    // Since the effect type is Layout, it executes at the same time as useLayoutEffect
    return mountEffectImpl(fiberFlags, Layout, imperativeHandleEffect.bind(null, create, ref), effectDeps);
}

As discussed previously in the article [‘In-depth Understanding of React Hooks (Part 1)’], useLayoutEffect and useImperativeHandle share the same execution timing in the Mutation phase. However, while useLayoutEffect executes a user-defined function, useImperativeHandle executes imperativeHandleEffect.bind(null, create, ref).

function imperativeHandleEffect(create, ref) {
   var refObject = ref;
  {
    if (!refObject.hasOwnProperty('current')) { // ref must have a current property
      error("Error");
    }
  }

  var instance = create(); // Calls the user-provided function to obtain an object where methods can be bound { func1, func2, ... }
  refObject.current = instance; // Assigns it to the parent component's reference
  return function () { // Provides a cleanup function to remove the reference
    refObject.current = null;
  };
}

The logic here is fairly straightforward: useImperativeHandle assigns a value to the parent component’s ref, allowing the parent component to access methods or state from the child. Alternatively, this can be accomplished by using a downgraded method:

function Child(props, ref) {
  useLayoutEffect(() => {
    ref.current = { /* Assign a new value to ref.current if deps change */ }
  }, [deps]);

  return (...);
}

Updating

When updating, useImperativeHandle calls updateImperativeHandle:

function updateImperativeHandle(ref, create, deps) {
    // Adds ref as a dependency
    var effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : null;
    // We have already analyzed updateEffectImpl and imperativeHandleEffect
    return updateEffectImpl(Update, Layout, imperativeHandleEffect.bind(null, create, ref), effectDeps);
}

In our previous discussion, we noted that updateEffectImpl skips execution when dependencies haven’t changed, allowing the commit phase to bypass it. This also applies here. If dependencies remain unchanged, imperativeHandleEffect won’t run, and the ref information remains intact; only when dependencies change will it reassign the latest value.