- React source code version: v16.11.0
- Source code commentary notes: airingursb/react
1. Introduction to useEffect#
1.1 Why use useEffect#
As mentioned earlier, React Hooks allow Functional Components to have the features of Class Components. The main motivations include:
- Reusing state logic between components is difficult.
- Complex components become hard to understand.
- Class components are hard to comprehend.
Regarding the second point, when writing React applications with Class Components, we often have to write code in various lifecycle methods, such as sending HTTP requests in componentDidMount
and componentDidUpdate
, binding events, or even doing some additional logic, which causes business logic to pile up in the lifecycle methods of the component. At this point, our programming mindset is "what do we need to do when the component is mounted" and "what do we need to do when the component updates," making React development lifecycle-oriented programming, and the logic we write in the lifecycle becomes the side effects of the component lifecycle methods.
Moreover, lifecycle-oriented programming leads to business logic being scattered across various lifecycle methods. For example, if we bind an event in componentDidMount
, we also need to unbind it in componentDidUnmount
, which makes event management logic inconsistent and the code scattered, making it more troublesome to review:
import React from 'react';
class A extends React.Component {
componentDidMount() {
document.getElementById('js_button')
.addEventListener('click', this.log);
}
componentDidUnmount() {
document.getElementById('js_button')
.removeEventListener('click', this.log);
}
log = () => {
console.log('log');
}
render() {
return (
<div id="js_button">button</div>
);
}
}
The emergence of useEffect
allows developers to shift their focus from the lifecycle back to business logic. In fact, the full name of effect is side effect, and useEffect
is used to handle the side effect logic that originally resided in lifecycle methods.
Next, let's look at how to use useEffect
.
1.2 How to use useEffect#
The above code rewritten with useEffect
looks like this:
import React, { useEffect } from 'react';
function A() {
const log = () => {
console.log('log');
}
useEffect(() => {
document
.getElementById('js_button')
.addEventListener('click', log);
return () => {
document
.getElementById('js_button')
.removeEventListener('click', log);
}
});
return (<div id="js_button">button</div>);
}
useEffect
accepts two parameters: the first parameter is a function that implements the bind operation and returns an unbind as a thunk function. The second parameter is an optional dependencies array. If the dependencies do not exist, the function will execute on every render; if the dependencies exist, the function will only execute when they change. Thus, we can infer that if the dependencies are an empty array, the function will execute only on the first render.
useEffect(
() => {
const subscription = props.source.subscribe();
return () => {
subscription.unsubscribe();
};
},
[props.source],
);
For more usage, please read the React official documentation on the useEffect API: https://reactjs.org/docs/hooks-reference.html#useeffect
2. The Principle and Simple Implementation of useEffect#
Based on the usage of useEffect
, we can implement a simple version of useEffect
ourselves:
let _deps;
function useEffect(callback, dependencies) {
const hasChanged = _deps
&& !dependencies.every((el, i) => el === _deps[i])
|| true;
// If dependencies do not exist, or dependencies have changed, execute callback
if (!dependencies || hasChanged) {
callback();
_deps = dependencies;
}
}
3. Analysis of useEffect Source Code#
3.1 mountEffect & updateEffect#
The entry point of useEffect
is the same as that of useState
in the file ReactFiberHooks.js. Just like useState
, during the first load, useEffect
actually executes mountEffect
, and then on each subsequent render, it executes updateEffect
. We need to focus on what mountEffect
and updateEffect
actually do.
For mountEffect
:
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect,
UnmountPassive | MountPassive,
create,
deps,
);
}
For updateEffect
:
function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(
UpdateEffect | PassiveEffect,
UnmountPassive | MountPassive,
create,
deps,
);
}
The parameters of mountEffect
and updateEffect
are a function and an array, corresponding to the callback and deps we passed to useEffect
. At the same time, we can see that mountEffect
and updateEffect
actually call mountEffectImpl
and updateEffectImpl
, which accept the same four parameters. The last two parameters are directly passed through, and the main focus is on what UpdateEffect | PassiveEffect
and UnmountPassive | MountPassive
are.
From reading the code, we can see they are imported from ReactSideEffectTags
and ReactHookEffectTags
.
import {
Update as UpdateEffect,
Passive as PassiveEffect,
} from 'shared/ReactSideEffectTags';
import {
NoEffect as NoHookEffect,
UnmountPassive,
MountPassive,
} from './ReactHookEffectTags';
Let's take a look at the definitions in ReactSideEffectTags.js and ReactHookEffectTags.js:
// Don't change these two values. They're used by React Dev Tools.
export const NoEffect = /* */ 0b0000000000000;
export const PerformedWork = /* */ 0b0000000000001;
// You can change the rest (and add more).
export const Placement = /* */ 0b0000000000010;
export const Update = /* */ 0b0000000000100;
export const PlacementAndUpdate = /* */ 0b0000000000110;
export const Deletion = /* */ 0b0000000001000;
export const ContentReset = /* */ 0b0000000010000;
export const Callback = /* */ 0b0000000100000;
export const DidCapture = /* */ 0b0000001000000;
export const Ref = /* */ 0b0000010000000;
export const Snapshot = /* */ 0b0000100000000;
export const Passive = /* */ 0b0001000000000;
export const Hydrating = /* */ 0b0010000000000;
export const HydratingAndUpdate = /* */ 0b0010000000100;
export const NoEffect = /* */ 0b00000000;
export const UnmountSnapshot = /* */ 0b00000010;
export const UnmountMutation = /* */ 0b00000100;
export const MountMutation = /* */ 0b00001000;
export const UnmountLayout = /* */ 0b00010000;
export const MountLayout = /* */ 0b00100000;
export const MountPassive = /* */ 0b01000000;
export const UnmountPassive = /* */ 0b10000000;
This design is intended to simplify type comparison and type composition. If you have experience designing some composite permission systems during project development, you might realize at first glance that UnmountPassive | MountPassive
is 0b11000000. If the corresponding bit is non-zero, it indicates that the tag implements the specified behavior. This will be used in the future, so we will just understand it for now.
3.2 mountEffectImpl & updateEffectImpl#
Next, let's look at the specific implementation of mountEffectImpl
and updateEffectImpl
.
3.2.1 mountEffectImpl#
First, mountEffectImpl
:
function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
const hook = mountWorkInProgressHook(); // Create a new Hook and return the current workInProgressHook
const nextDeps = deps === undefined ? null : deps;
sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps);
}
mountWorkInProgressHook
, which we analyzed in Section 3.4.3: mountWorkInProgressHook, creates a new Hook and returns the current workInProgressHook, and the specific principle will not be elaborated here.
sideEffectTag
is bitwise OR'd with fiberEffectTag
and then assigned, mounted on renderedWork.effectTag
in renderWithHooks
, and reset to 0 after each render.
renderedWork.effectTag |= sideEffectTag;
sideEffectTag = 0;
We will discuss the specific use of renderedWork.effectTag
later.
renderWithHooks
is analyzed in Section 3.4.3: renderWithHooks, and will not be elaborated here.
hook.memoizedState
records the result returned by pushEffect
, which is consistent with the principle of recording newState
in useState
. Now the focus shifts to what pushEffect
actually does.
3.3.2 updateEffectImpl#
Next, let's see what updateEffectImpl
does:
function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
const hook = updateWorkInProgressHook(); // Get the current working Hook
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(NoHookEffect, create, destroy, nextDeps);
return;
}
}
}
sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}
updateWorkInProgressHook
, which we analyzed in Section 3.4.3: updateWorkInProgressHook, retrieves the current working Hook, and the specific principle will not be elaborated here.
It can be seen that when currentHook
is null, the logic of updateEffectImpl
is identical to that of mountEffectImpl
. When currentHook
is not null, the third parameter of pushEffect
is not undefined but is destroy
. Moreover, in this branch, there exists areHookInputsEqual(nextDeps, prevDeps)
, which means that if the current useEffect
's deps are equal to the previous useEffect
's deps (what areHookInputsEqual
does is traverse and compare whether the two deps are equal, which will not be elaborated here), then it executes pushEffect(NoHookEffect, create, destroy, nextDeps);
. It can be boldly speculated that NoHookEffect
means not to execute this useEffect
. Thus, the logic of this code is consistent with the useEffect
we implemented earlier.
According to Section 3.4.3: updateWorkInProgressHook, we know that currentHook
is the Hook currently being processed, and under normal logic, it should not be null. Next, we need to focus on what pushEffect
does and what the third parameter means.
3.3 pushEffect#
function pushEffect(tag, create, destroy, deps) {
// Declare a new effect
const effect: Effect = {
tag,
create,
destroy,
deps,
// Circular
next: (null: any), // Reference to the next effect defined in the function component
};
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue(); // Initialize componentUpdateQueue
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
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;
}
type Effect = {
tag: HookEffectTag, // A binary number that determines the behavior of the effect
create: () => (() => void) | void, // Callback to run after rendering
destroy: (() => void) | void, // Determines whether to destroy and recreate the effect
deps: Array<mixed> | null, // Deps that determine whether to execute after re-rendering
next: Effect, // Reference to the next effect defined in the function component
};
This function first declares a new effect based on the parameters, and the data structure is also provided. It is also a circular linked list. The tag is
Next, it follows two logic paths based on whether componentUpdateQueue
is empty:
-
When
componentUpdateQueue
is empty: This is the logic duringmountEffect
, which creates an emptycomponentUpdateQueue
, which is actually just{lastEffect: null}
, and then pointscomponentUpdateQueue.lastEffect
toeffect.next
, essentially just storing the effect. -
When
componentUpdateQueue
is not empty: This is the logic that will be followed duringupdateEffect
.- If
lastEffect
is empty: This means it is the firstuseEffect
of the new render phase, and the logic is consistent with whencomponentUpdateQueue
is empty. - If
lastEffect
is not empty: This means that this component has multipleuseEffect
, and it will follow the logic of the second and subsequentuseEffect
, pointinglastEffect
to the next effect.
- If
Finally, it returns an effect.
3.4 React Fiber Process Analysis#
It seems that the source code ends here, but we still have several unresolved questions:
- What do those binary numbers in
effect.tag
mean? - What logic follows
pushEffect
? - Where will
componentUpdateQueue
, after storing Effect, be used?
In renderWithHooks
, componentUpdateQueue
will be assigned to renderedWork.updateQueue
, and the sideEffectTag
we discussed in 3.2 will also be assigned to renderedWork.effectTag
.
renderedWork.updateQueue = (componentUpdateQueue: any);
renderedWork.effectTag |= sideEffectTag;
In Section 3.4.3: renderWithHooks, we analyzed that renderWithHooks
is executed during the function component update phase (updateFunctionComponent
). To answer the three questions above, we must go through the entire Reconciler process. I personally believe that Fiber is the most complex logic in React 16, so in the previous articles, I only briefly mentioned it without elaborating. There is a lot of content in Fiber, and if elaborated, it would be enough to write several articles. Therefore, we will try to go through the process simply and quickly, ignoring details unrelated to this article and focusing on the implementation of some logic after we call useEffect
.
Note: If you are not interested in this part, you can jump directly to 3.5 to continue reading.
There are many excellent articles about React Fiber; here are a few more articles and videos to help interested readers understand:
Let's get started!
3.4.1 ReactDOM.js#
The only entry point for rendering the page is ReactDOM.render
:
ReactRoot.prototype.render = ReactSyncRoot.prototype.render = function(
children: ReactNodeList,
callback: ?() => mixed,
): Work {
// ... ignore unrelated code
updateContainer(children, root, null, work._onCommit);
return work;
};
The core of render
is calling updateContainer
, which comes from the ReactFiberReconciler.js in react-reconciler.
3.4.2 ReactFiberReconciler.js#
This file is also the entry point of react-reconciler. Let's see what updateContainer
is:
export function updateContainer(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
callback: ?Function,
): ExpirationTime {
// ... ignore unrelated code
return updateContainerAtExpirationTime(
element,
container,
parentComponent,
expirationTime,
suspenseConfig,
callback,
);
}
Ignoring unrelated code, we find that it is just a layer of encapsulation for updateContainerAtExpirationTime
. Let's see what this is:
export function updateContainerAtExpirationTime(
element: ReactNodeList,
container: OpaqueRoot,
parentComponent: ?React$Component<any, any>,
expirationTime: ExpirationTime,
suspenseConfig: null | SuspenseConfig,
callback: ?Function,
) {
// ... ignore unrelated code
return scheduleRootUpdate(
current,
element,
expirationTime,
suspenseConfig,
callback,
);
}
Ignoring some unrelated code, we find that its core does two things: enqueueUpdate
, which we will temporarily ignore, and the task scheduling scheduleWork
, which is defined in ReactFiberWorkLoop.js.
3.4.3 ReactFiberWorkLoop.js - render#
The content of ReactFiberWorkLoop.js is very long, with 2900 lines of code, containing the main logic of the task loop. However, since we just clarified that we need to start from scheduleWork
, let's sort it out slowly:
export function scheduleUpdateOnFiber(
fiber: Fiber,
expirationTime: ExpirationTime,
) {
// ... ignore unrelated code
const priorityLevel = getCurrentPriorityLevel();
if (expirationTime === Sync) {
if (
(executionContext & LegacyUnbatchedContext) !== NoContext &&
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
schedulePendingInteractions(root, expirationTime);
let callback = renderRoot(root, Sync, true);
while (callback !== null) {
callback = callback(true);
}
} else {
scheduleCallbackForRoot(root, ImmediatePriority, Sync);
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
}
} else {
scheduleCallbackForRoot(root, priorityLevel, expirationTime);
}
// ... ignore special case handling
}
export const scheduleWork = scheduleUpdateOnFiber;
Most branches of this code will return to renderRoot
, so rather than saying scheduleWork
is the entry point of Fiber logic, it is more accurate to say renderRoot
is the entry point. renderRoot
is the famous render phase in the two phases of Fiber.
Image source: A Cartoon Intro to Fiber - React Conf 2017
Debugging also makes it easy to see these two phases:
The code in renderRoot
is also very complex; we will focus on the logic related to this article:
function renderRoot(
root: FiberRoot,
expirationTime: ExpirationTime,
isSync: boolean,
): SchedulerCallback | null {
if (isSync && root.finishedExpirationTime === expirationTime) {
// There's already a pending commit at this expiration time.
return commitRoot.bind(null, root); // Enter the commit phase
}
// ...
do {
try {
if (isSync) {
workLoopSync();
} else {
workLoop(); // Core logic
}
break;
} catch (thrownValue) {
// ...
} while (true);
// ...
}
After omitting some extraneous code, we focus on two important points:
workLoop
is the core part of the code, implementing the task loop with a loop.- In the case of timeout, it will enter the commit phase.
First, let's look at the logic of workLoop
:
function workLoop() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
It seems we need to focus on performUnitOfWork
:
function performUnitOfWork(unitOfWork: Fiber): Fiber | null {
const current = unitOfWork.alternate;
// ... ignore timing logic
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
next = beginWork(current, unitOfWork, renderExpirationTime);
} else {
next = beginWork(current, unitOfWork, renderExpirationTime);
}
// ... ignore special logic
ReactCurrentOwner.current = null;
return next;
}
Ignoring timing logic, we find that this code essentially calls beginWork
twice (this answers the question we left in Section 3.4.1). This beginWork
is imported from ReactFiberBeginWork.js.
3.4.4 ReactFiberBeginWork.js#
The analysis of this section is similar to Section 3.4.1: renderWithHooks and will not be elaborated here.
Thus, the updateQueue
(remember it? Its content is the Effect linked list) and effectTag
we have on renderedWork
are mounted on Fiber. Skipping this part of the logic, let's see how Fiber processes them.
3.4.5 ReactFiberWorkLoop.js - commit#
In the process of analyzing renderRoot
, we noticed that after a task times out, it directly enters the commit phase. Let's first look at the code for commitRoot
:
function commitRoot(root) {
const renderPriorityLevel = getCurrentPriorityLevel();
runWithPriority(
ImmediatePriority,
commitRootImpl.bind(null, root, renderPriorityLevel),
);
return null;
}
Here, we find that we should focus on commitRootImpl
, so let's take a look:
function commitRootImpl(root, renderPriorityLevel) {
// ...
startCommitTimer();
// Get the list of effects.
let firstEffect;
if (finishedWork.effectTag > PerformedWork) {
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
firstEffect = finishedWork.firstEffect;
}
if (firstEffect !== null) {
do {
try {
commitBeforeMutationEffects();
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitSnapshotEffectsTimer();
if (enableProfilerTimer) {
// Mark the current commit time to be shared by all Profilers in this
// batch. This enables them to be grouped later.
recordCommitTime();
}
// The next phase is the mutation phase, where we mutate the host tree.
startCommitHostEffectsTimer();
nextEffect = firstEffect;
do {
try {
commitMutationEffects(root, renderPriorityLevel);
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitHostEffectsTimer();
resetAfterCommit(root.containerInfo);
// The work-in-progress tree is now the current tree. This must come after
// the mutation phase, so that the previous tree is still current during
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
root.current = finishedWork;
// The next phase is the layout phase, where we call effects that read
// the host tree after it's been mutated. The idiomatic use case for this is
// layout, but class component lifecycles also fire here for legacy reasons.
startCommitLifeCyclesTimer();
nextEffect = firstEffect;
do {
try {
commitLayoutEffects(root, expirationTime);
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
stopCommitLifeCyclesTimer();
nextEffect = null;
// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
requestPaint();
if (enableSchedulerTracing) {
__interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
}
executionContext = prevExecutionContext;
} else {
// No effects.
// ...
}
stopCommitTimer();
nextEffect = firstEffect;
while (nextEffect !== null) {
const nextNextEffect = nextEffect.nextEffect;
nextEffect.nextEffect = null;
nextEffect = nextNextEffect;
}
// ...
return null;
}
The code for commitRootImpl
is indeed very long. I have omitted some code unrelated to effect processing. After reading the remaining code, we find that when effects exist, there are three segments of logic to process, which essentially loop through the effect linked list and pass them to three different functions:
- commitBeforeMutationEffects
- commitMutationEffects
- commitLayoutEffects
Finally, it loops through the effects, setting nextEffect
to nextNextEffect
.
Due to space limitations, and since the third function is related to useLayoutEffect
, we will not elaborate on these three functions here. We will analyze useLayoutEffect
in the next article in detail. Therefore, the question we left in Section 3.4 regarding what those binary numbers in effect.tag
mean will also be explained in the next article.
Here, we have clarified the source code of useEffect
, but one question remains: what is the purpose of the effect.tag
parameter? Currently, we only know that when it is NoHookEffect
, it means not to execute the content of useEffect
, but we have not analyzed the meanings of other values. Their analysis logic is mainly in the three functions we skipped in Section 3.4.5. In the next article, we will take a closer look at useLayoutEffect
.
See you all next time.
Finally, here is the flowchart from Section 3.4 analysis: