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.
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.
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.
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.