foreword
Since the release of React Hooks, the entire community has embraced it and learned from it with a positive attitude. During the period, many articles about React Hooks source code analysis also emerged. In this article, I will write an article of my own from the author's own point of view. I hope that it can help you learn and understand the implementation principle of React Hooks with simple words and pictures. This article will present the content in the form of text, code, and pictures. Mainly learn the useState, useReducer, and useEffect in commonly used Hooks, and uncover the veil of Hooks as much as possible.
Doubts when using Hooks
With the advent of Hooks, our Function Component gradually has the characteristics of the standard Class Component, such as private state, life cycle functions, etc. The two Hooks useState and useReducer allow us to use private state in Function Component. And useState is actually a castrated version of useReducer, which is why I put them together. Apply the official example:
function PersionInfo ({initialAge,initialName}) { const [age, setAge] = useState(initialAge); const [name, setName] = useState(initialName); return ( <> Age: {age}, Name: {name} <button onClick={() => setAge(age + 1)}>Growing up</button> </> ); }
With useState we can initialize a private state that returns the latest value of the state and a method to update the state. And useReducer is for more complex state management scenarios:
const initialState = {age: 0, name: 'Dan'}; function reducer(state, action) { switch (action.type) { case 'increment': return {...state, age: state.age + action.age}; case 'decrement': return {...state, age: state.age - action.age}; default: throw new Error(); } } function PersionInfo() { const [state, dispatch] = useReducer(reducer, initialState); return ( <> Age: {state.age}, Name: {state.name} <button onClick={() => dispatch({type: 'decrement', age: 1})}>-</button> <button onClick={() => dispatch({type: 'increment', age: 1})}>+</button> </> ); }
It also returns the current latest state, and returns a method for updating data. When using these two methods, maybe we will think about the following questions:
const [age, setAge] = useState(initialAge); const [name, setName] = useState(initialName);
How does React distinguish between these two states? Unlike Class Component, Function Component can mount the private state into the class instance and point to the corresponding state through the corresponding key, and every time the page is refreshed or the component is re-rendered, the Function will be re-executed. So there must be a mechanism in React to distinguish these Hooks.
const [age, setAge] = useState(initialAge); // or const [state, dispatch] = useReducer(reducer, initialState);
Another question is how does React return the latest state after each re-render? Because of its own characteristics, Class Component can persistently mount the private state to the class instance, and save the latest value every moment. And Function Component is a function because of its essence, and it will be re-executed every time it is rendered. So React must have some mechanism to remember every update operation, and finally return the latest value. Of course, we will have other questions, such as where are these states stored? Why can Hooks only be used at the top level of functions but not in conditional statements etc.?
The answer is in the source code
Let's first understand the source code implementation of useState and useReducer, and answer our doubts when using Hooks. First we start at the source:
import React, { useState } from 'react';
In the project, we usually introduce the useState method in this way. What does the useState method introduced by us look like? In fact, this method is in the source code packages/react/src/ReactHook.js.
// packages/react/src/ReactHook.js import ReactCurrentDispatcher from './ReactCurrentDispatcher'; function resolveDispatcher() { const dispatcher = ReactCurrentDispatcher.current; // ... return dispatcher; } // The useState method introduced in our code export function useState(initialState) { const dispatcher = resolveDispatcher(); return dispatcher.useState(initialState) }
As you can see from the source code, what we call is dispatcher.useState() in ReactCurrentDispatcher.js, so let’s go to the ReactCurrentDispatcher.js file:
import type {Dispacther} from 'react-reconciler/src/ReactFiberHooks'; const ReactCurrentDispatcher = { current: (null: null | Dispatcher), }; export default ReactCurrentDispatcher;
Well, it continues to bring us to the react-reconciler/src/ReactFiberHooks.js file. So let's move on to this file.
// react-reconciler/src/ReactFiberHooks.js export type Dispatcher = { useState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>], useReducer<S, I, A>( reducer: (S, A) => S, initialArg: I, init?: (I) => S, ): [S, Dispatch<A>], useEffect( create: () => (() => void) | void, deps: Array<mixed> | void | null, ): void, // Other hooks type definition }
After going around and around, we finally figured out that the source code of React Hooks is placed under the react-reconciler/src/ReactFiberHooks.js directory. Here, as shown in the figure above, we can see the type definition of each Hooks. At the same time, we can also see the specific implementation of Hooks. You can read more about this file. First of all, we noticed that most of our Hooks have two definitions:
// react-reconciler/src/ReactFiberHooks.js // Definition of Hooks in the Mount phase const HooksDispatcherOnMount: Dispatcher = { useEffect: mountEffect, useReducer: mountReducer, useState: mountState, // Other Hooks }; // Definition of Hooks in the Update phase const HooksDispatcherOnUpdate: Dispatcher = { useEffect: updateEffect, useReducer: updateReducer, useState: updateState, // Other Hooks };
It can be seen from this that the logic of our Hooks is different in the Mount phase and the Update phase. They are two different definitions in the Mount phase and the Update phase. Let's first look at the logic of the Mount phase. Before looking, let's think about some questions. What do React Hooks need to do during the Mount phase? Take our useState and useReducer as an example:
- We need to initialize the state, and return methods to modify the state, which is the most basic.
- We need to manage each Hooks separately.
- Provide a data structure to store the update logic, so that each subsequent update can get the latest value.
Let's take a look at the implementation of React, first look at the implementation of mountState.
// react-reconciler/src/ReactFiberHooks.js function mountState (initialState) { // Get the current Hook node and add the current Hook to the Hook linked list const hook = mountWorkInProgressHook(); if (typeof initialState === 'function') { initialState = initialState(); } hook.memoizedState = hook.baseState = initialState; // Declare a linked list to store updates const queue = (hook.queue = { last: null, dispatch: null, lastRenderedReducer, lastRenderedState, }); // Return a dispatch method to modify the state, and add this update to the update list const dispatch = (queue.dispatch = (dispatchAction.bind( null, currentlyRenderingFiber, queue, ))); // Methods that return the current state and modify the state return [hook.memoizedState, dispatch]; }
Differentiate management Hooks
Regarding the first thing, the methods that initialize the state and return the state and update the state. There is no problem with this, and the source code is also very clear. Use initialState to initialize the state, and return the state and the corresponding update method return [hook.memoizedState, dispatch]. So let's take a look at how React distinguishes different Hooks. Here we can find the answer from the mountWorkInProgressHook method in mountState and the Hook type definition. Related reference video explanation: enter study
// react-reconciler/src/ReactFiberHooks.js export type Hook = { memoizedState: any, baseState: any, baseUpdate: Update<any, any> | null, queue: UpdateQueue<any, any> | null, next: Hook | null, // Point to the next Hook };
First of all, it can be seen from the type definition of Hook that React defines Hooks as a linked list. That is to say, the Hooks used in our component are connected through a linked list, and the next of the previous Hooks points to the next Hooks. How are these Hooks nodes connected in series using the linked list data structure? The relevant logic is in the mountWorkInProgressHook method called by the Hooks function in each specific mount stage:
// react-reconciler/src/ReactFiberHooks.js function mountWorkInProgressHook(): Hook { const hook: Hook = { memoizedState: null, baseState: null, queue: null, baseUpdate: null, next: null, }; if (workInProgressHook === null) { // If the current workInProgressHook linked list is empty, // Use the current Hook as the first Hook firstWorkInProgressHook = workInProgressHook = hook; } else { // Otherwise, add the current Hook to the end of the Hook list workInProgressHook = workInProgressHook.next = hook; } return workInProgressHook; }
In the mount phase, whenever we call the Hooks method, such as useState, mountState will call mountWorkInProgressHook to create a Hook node and add it to the Hooks list. Like our example:
const [age, setAge] = useState(initialAge); const [name, setName] = useState(initialName); useEffect(() => {})
Then in the mount phase, a singly linked list like the following figure will be produced:
return the latest value
As for the third thing, both useState and useReducer use a queue list to store each update. So that the later update phase can return the latest state. Every time we call the dispatchAction method, a new updata object will be formed and added to the queue list, and this is a circular list. You can take a look at the implementation of the dispatchAction method:
// react-reconciler/src/ReactFiberHooks.js // Remove special cases and fiber-related logic function dispatchAction(fiber,queue,action,) { const update = { action, next: null, }; // Add the update object to the circular linked list const last = queue.last; if (last === null) { // The linked list is empty, take the current update as first, and keep looping update.next = update; } else { const first = last.next; if (first !== null) { // Insert a new update object after the latest update object update.next = first; } last.next = update; } // Keep the table header on the latest update object queue.last = update; // To schedule work scheduleWork(); }
That is, every time we execute the dispatchAction method, such as setAge or setName. An update object that saves the update information will be created and added to the update list queue. Then each Hooks node will have its own queue. For example, suppose we executed the following statements:
setAge(19); setAge(20); setAge(21);
Then our Hooks linked list will become like this:
On the Hooks node, as shown in the figure above, all historical update operations are stored through a linked list. So that in the update phase, the latest values can be obtained and returned to us through these updates. This is why after the first call to useState or useReducer, every update returns the latest value. Take a look at mountReducer again, you will find that it is almost the same as mountState, but the state initialization logic is slightly different. After all, useState is actually a castrated version of useReducer. I won't introduce mountReducer in detail here.
// react-reconciler/src/ReactFiberHooks.js function mountReducer(reducer, initialArg, init,) { // Get the current Hook node and add the current Hook to the Hook linked list const hook = mountWorkInProgressHook(); let initialState; // initialization if (init !== undefined) { initialState = init(initialArg); } else { initialState = initialArg ; } hook.memoizedState = hook.baseState = initialState; // Linked list to store updated objects const queue = (hook.queue = { last: null, dispatch: null, lastRenderedReducer: reducer, lastRenderedState: (initialState: any), }); // Return a dispatch method to modify the state, and add this update to the update list const dispatch = (queue.dispatch = (dispatchAction.bind( null, currentlyRenderingFiber, queue, ))); // Methods that return state and modify state return [hook.memoizedState, dispatch]; }
Then let's take a look at the update phase, that is, to see how our useState or useReducer uses the existing information to return us the latest and most correct value. Let's take a look at the code of useState in the update phase, which is updateState:
// react-reconciler/src/ReactFiberHooks.js function updateState(initialState) { return updateReducer(basicStateReducer, initialState); }
It can be seen that the bottom layer of updateState will actually kill updateReducer, because when we call useState, the reducer will not be passed in, so a basicStateReducer will be passed in by default. Let's take a look at this basicStateReducer first:
// react-reconciler/src/ReactFiberHooks.js function basicStateReducer(state, action){ return typeof action === 'function' ? action(state) : action; }
When using useState(action), action is usually a value, not a method. So what baseStateReducer has to do is to return this action. Let's continue to look at the logic of updateReducer:
// react-reconciler/src/ReactFiberHooks.js // Remove the logic related to fiber function updateReducer(reducer,initialArg,init) { const hook = updateWorkInProgressHook(); const queue = hook.queue; // Get the header of the updated list const last = queue.last; // Get the earliest update object first = last !== null ? last.next : null; if (first !== null) { let newState; let update = first; do { // Execute each update to update the state const action = update.action; newState = reducer(newState, action); update = update.next; } while (update !== null && update !== first); hook.memoizedState = newState; } const dispatch = queue.dispatch; // Methods that return the latest state and modify state return [hook.memoizedState, dispatch]; }
In the update phase, that is, the second and third time of our component. . When executing useState or useReducer, it will traverse the circular list of update objects, execute each update to calculate the latest state and return it, so as to ensure that we can get the latest state every time we refresh the component. The reducer of useState is baseStateReducer, because the incoming update.action is a value, so it will directly return update.action, and the reducer of useReducer is a user-defined reducer, so it will be calculated step by step according to the incoming action and the newState obtained in each cycle out the latest status.
useState/useReducer Summary
Seeing here we are looking back at some of the original questions:
How does React manage the distinction between Hooks?
- React manages Hooks through a single linked list
- Add Hook nodes to the linked list in sequence according to the execution order of Hooks
How do useState and useReducer return the latest value every time they render?
- Each Hook node remembers all update operations through a circular linked list
- In the update phase, all update operations in the update circular linked list will be executed sequentially, and finally the latest state will be returned
Why can't I use Hooks in conditional statements etc.?
- Linked list!
For example, as shown in the figure, we call useState('A'), useState('B'), useState('C') in the mount phase, if we put useState('B') in the conditional statement, and If it is not executed in the update phase because the conditions are not met, then the information cannot be correctly retrieved from the Hooks linked list. React will also give us an error.
Where is the Hooks linked list?
Ok, now we have learned that React manages Hooks through a linked list, and also stores each update operation through a circular linked list, so that the latest status can be calculated and returned to us every time a component is updated. So where is our Hooks linked list stored? Of course we need to store it in a place relative to the current component. Then it is obvious that this one-to-one correspondence with components is our FiberNode.
As shown in the figure, the Hooks linked list built by the component will be mounted on the memoizedState of the FiberNode node.
useEffect
Seeing this, I believe you already have a certain understanding of the source code implementation mode of Hooks, so if you try to see the implementation of Effect, you will understand it at once. First of all, let's recall how useEffect works?
function PersionInfo () { const [age, setAge] = useState(18); useEffect(() =>{ console.log(age) }, [age]) const [name, setName] = useState('Dan'); useEffect(() =>{ console.log(name) }, [name]) return ( <> ... </> ); }
When the PersionInfo component is rendered for the first time, it will output the age and name on the console. In each update of the subsequent components, if the value of the deps dependency in useEffect changes, the corresponding state will also be output on the console. At the same time The cleanup function (if any) will be executed when unmount ing. How is it implemented in React? In fact, it is very simple. An updateQueue is used to store all the effects in the FiberNode, and then all the effects that need to be executed are executed sequentially after each rendering. useEffect is also divided into mountEffect and updateEffect
mountEffect
// react-reconciler/src/ReactFiberHooks.js // Simplify and remove special logic function mountEffect( create,deps,) { return mountEffectImpl( create, deps, ); } function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps) { // Get the current Hook and add the current Hook to the Hook list const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; // Save the current effect to the memoizedState property of the Hook node, // And added to the updateQueue of fiberNode hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps); } function pushEffect(tag, create, destroy, deps) { const effect: Effect = { tag, create, destroy, deps, next: (null: any), }; // componentUpdateQueue will be mounted on the updateQueue of fiberNode if (componentUpdateQueue === null) { // If the current Queue is empty, use the current effect as the first node componentUpdateQueue = createFunctionComponentUpdateQueue(); // keep the loop componentUpdateQueue.lastEffect = effect.next = effect; } else { // Otherwise, add to the current Queue linked list const lastEffect = componentUpdateQueue.lastEffect; if (lastEffect === null) { componentUpdateQueue.lastEffect = effect.next = effect; } else { const firstEffect = lastEffect.next; lastEffect.next = effect; effect.next = firstEffect; componentUpdateQueue.lastEffect = effect; } } return effect; }
It can be seen that in the mount phase, what useEffect does is to add its own effect to the componentUpdateQueue. This componentUpdateQueue will be assigned to the updateQueue of fiberNode in the renderWithHooks method.
// react-reconciler/src/ReactFiberHooks.js // Simplify and remove special logic export function renderWithHooks() { const renderedWork = currentlyRenderingFiber; renderedWork.updateQueue = componentUpdateQueue; }
That is, in the mount phase, all our effect s are mounted on the fiberNode in the form of a linked list. Then after the component is rendered, React will execute all the methods in updateQueue.
updateEffect
// react-reconciler/src/ReactFiberHooks.js // Simplify and remove special logic function updateEffect(create,deps){ return updateEffectImpl( create, deps, ); } function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps){ // Get the current Hook node and add it to the Hook list const hook = updateWorkInProgressHook(); // rely const nextDeps = deps === undefined ? null : deps; // Clear function let destroy = undefined; if (currentHook !== null) { // Get the effect of the previous rendering of the Hook node const prevEffect = currentHook.memoizedState; destroy = prevEffect.destroy; if (nextDeps !== null) { const prevDeps = prevEffect.deps; // Compare deps dependencies if (areHookInputsEqual(nextDeps, prevDeps)) { // If there is no change in the dependency, the NoHookEffect tag will be marked, and this will be skipped in the commit phase // Execution of effect s pushEffect(NoHookEffect, create, destroy, nextDeps); return; } } } hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps); }
The update phase is similar to the mount phase, except that this time the effect's dependency deps will be considered. If there is no change in the dependency of the updated effect, it will be marked as NoHookEffect, and finally the execution of the modified effect will be skipped in the commit phase.
function commitHookEffectList(unmountTag,mountTag,finishedWork) { const updateQueue = finishedWork.updateQueue; let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null; if (lastEffect !== null) { const firstEffect = lastEffect.next; let effect = firstEffect; do { if ((effect.tag & unmountTag) !== NoHookEffect) { // In the Unmount phase, execute the tag !== NoHookEffect’s effect cleanup function (if any) const destroy = effect.destroy; effect.destroy = undefined; if (destroy !== undefined) { destroy(); } } if ((effect.tag & mountTag) !== NoHookEffect) { // The Mount phase executes the effect.create of all tag !== NoHookEffect, // Our cleanup function (if any) will be returned to the destroy property and executed once by unmount const create = effect.create; effect.destroy = create(); } effect = effect.next; } while (effect !== firstEffect); } }
useEffect summary
What does useEffect do?
- There will be another updateQueue linked list in the FiberNdoe node to store all the effect s that need to be executed for this rendering.
- The mountEffect phase and the updateEffect phase will mount the effect to the updateQueue.
- In the updateEffect phase, the effect whose deps has not changed will be marked with the NoHookEffect tag, and the effect will be skipped in the commit phase.
So far, the source code of useState/useReducer/useEffect has also been read. I believe that with these foundations, reading the source code of the remaining Hooks will not be a problem, and finally put a complete diagram: