TLNR#
I hope this article can help readers understand the following points:
- When
use(layout)Effect
is called, an effect object is created, and this object is mounted to the fiber's updateQueue in the form of a linked list. useEffect
is scheduled by the Scheduler with NormalSchedulerPriority, making it an asynchronous operation.- The create of
useLayoutEffect
is processed synchronously during the layout phase, while its previous round's destroy is processed synchronously during the mutation phase. Therefore, calling time-consuming operations within this hook will block page updates.
Pre-reading Notes#
useEffect
involves interaction with the Scheduler, so understanding its execution order requires some knowledge of React's scheduling behavior. In simple terms, it can be understood as follows (not a rigorous statement, just for understanding):- All tasks registered into the Scheduler via
scheduleCallback
are asynchronous tasks, and the task type is Task. - React has a high-priority preemption mechanism, which may interrupt the scheduling of
useEffect
, leading to unexpected situations.
- All tasks registered into the Scheduler via
Explanation#
We will continue to use the example code from React hooks: hooks linked list.
function UseEffectAnduseLayoutEffect() {
const [text, setText] = useState(0);
useEffect(() => {
console.log('useEffect create');
return () => {
console.log('useEffect destroy');
}
}, [text]);
useLayoutEffect(() => {
console.log('useLayoutEffect create');
return () => {
console.log('useLayoutEffect destroy');
}
}, [text]);
return (
<div onClick={ () => { setText(1) } }>{ text }</div>
)
}
Based on previous debugging experience, we know that each hook call invokes mountWorkInProgressHook
to create a hook object, forming a linked list structure based on the call order, and this hook linked list will be mounted to the corresponding fiber object's memoizedState
property.
Moreover, the logic of each different type of hook is not the same; here we will only analyze useEffect
. But before that, let's establish a naming convention for its structure.
use(Layout)Effect(() => { // Convention: its first parameter is create
return () => { // Convention: the return value of the create function is destroy
}
}, []); // Convention: its second parameter is dependencies
Effect Linked List#
The construction of the effect linked list occurs within beginWork
.
The hook executes different logic based on whether the component is mounting or updating. For use(Layout)Effect
, it calls mount(Layout)Effect
during mounting and update(Layout)Effect
during updating.
Generating the Corresponding Effect Linked List During Mounting#
We first set a breakpoint on useEffect
in the example, which will enter mountEffect
and then mountEffectImpl
.
mountEffectImpl(
UpdateEffect | PassiveEffect, // In React, there are many bit operations regarding permissions; here, the **or** operation often implies granting permissions.
HookPassive,
create,
deps,
);
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
/**
* The hook linked list has been analyzed, so we skip it.
*/
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
/**
* A |= B => A = A | B
*/
currentlyRenderingFiber.flags |= fiberFlags;
/**
* This logic is a key point, involving the creation of the effect object and the linking of the list.
* It is also important to note that the `memoizedState` on the corresponding hook object of `useEffect` will be mounted with the effect linked list.
* The logic of `useLayoutEffect` is roughly the same as that of `useEffect`, but it will label the effect object differently.
*/
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps,
);
}
pushEffect
is the key to forming the effect linked list. The generated effect circular linked list will be mounted to the current component's fiber's updateQueue
, and updateQueue.lastEffect
will point to the latest generated effect object.
The structure of the effect object is as follows:
const effect: Effect = {
tag,
create, // create of use(Layout)Effect
destroy, // destroy of use(Layout)Effect
deps, // dependencies of use(Layout)Effect
// Circular
next: (null: any), // points to the next effect
};
After executing mountEffectImpl
, the generated effect linked list will be mounted in two places, and useEffect
will finish running at this point:
- The
memoizedState
of the corresponding hook element ofuse(Layout)Effect
. fiber.updateQueue
.
The subsequent execution of useLayoutEffect
also follows the same steps, so the effect linked list will look like this:
In fact, this is indeed the case.
Thus, the logic related to generating the effect linked list during the mounting phase has concluded.
Generating the Corresponding Effect Linked List During Updating#
Keep the original breakpoint and trigger the click callback, which will enter updateEffect
and then updateEffectImpl
.
updateEffectImpl(
UpdateEffect | PassiveEffect,
HookPassive,
create,
deps,
)
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook(); // In the update case, currentHook will be assigned.
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState; // Get the effect object corresponding to currentHook.
destroy = prevEffect.destroy; // Assign the previous destroy function to wip and call it this time.
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) { // Compare new and old deps one by one, but it's a shallow comparison.
pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags, // HookHasEffect marks the update.
create,
destroy,
nextDeps,
);
}
Unlike mounting, the update's pushEffect
will carry the previous destroy function.
About the Execution Order of Create and Destroy in useEffect#
At this point, we set a breakpoint on the create function of useEffect
to observe its call stack.
Here we notice the function flushPassiveEffectsImpl
, which is scheduled at NormalSchedulerPriority level in flushPassiveEffects
, making flushPassiveEffectsImpl
an asynchronous task with a low priority. However, the current example does not need to consider preemption.
By globally searching for flushPassiveEffects
(the clue is the assignment of pendingPassiveHookEffectsMount
), we finally locate the calling position at commitBeforeMutationEffects
.
Querying asynchronous operation calls is cumbersome, but we eventually pinpoint it at commitRoot
-> commitRootImpl
-> commitBeforeMutationEffects
. This function is the entry point for the BeforeMutation phase.
Collecting Effects#
Before executing flushPassiveEffectsImpl
to clear effects, we first need to collect the effect linked list on the fiber. This operation occurs within schedulePassiveEffects
. Since schedulePassiveEffects
is called within commitLayoutEffects
-> commitLifeCycles
, it can be viewed as being called during the layout phase.
/**
* Here, finishedWork is the fiber object of UseEffectAnduseLayoutEffect.
*/
function schedulePassiveEffects(finishedWork: Fiber) {
/**
* The corresponding effect linked list is mounted on the fiber's updateQueue.
*/
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
// The lastEffect pointer points to the last effect object, so we can directly get the first element through next.
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const {next, tag} = effect;
if (
(tag & HookPassive) !== NoHookEffect && // Filters out effect objects created by non-use(Layout)Effect.
(tag & HookHasEffect) !== NoHookEffect // Filters out effect objects where dependencies have not changed.
) {
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect); // Assigns to pendingPassiveHookEffectsUnmount.
enqueuePendingPassiveHookEffectMount(finishedWork, effect); // Assigns to pendingPassiveHookEffectsMount.
// Currently, after executing pendingPassiveHookEffectsUnmount and pendingPassiveHookEffectsMount, the data is consistent.
}
effect = next;
} while (effect !== firstEffect); // Stops after one loop of the circular linked list.
}
}
Executing Effects#
Directly debug flushPassiveEffectsImpl
; the following code has removed redundant parts. This function involves the logic for executing the create and destroy on the effect objects.
function flushPassiveEffectsImpl() {
...
/**
* Execute the destroy function of the effect.
* During mounting, since the third parameter in pushEffect is undefined, the destroy property on the effect object is empty. Therefore, destroy will not be executed.
* However, during updating, the destroy function returned by create will be passed in, so it will be executed. This will be mentioned later.
*/
const unmountEffects = pendingPassiveHookEffectsUnmount;
pendingPassiveHookEffectsUnmount = [];
for (let i = 0; i < unmountEffects.length; i += 2) {
const effect = ((unmountEffects[i]: any): HookEffect);
const fiber = ((unmountEffects[i + 1]: any): Fiber);
const destroy = effect.destroy;
effect.destroy = undefined;
if (typeof destroy === 'function') {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
fiber.mode & ProfileMode
) {
try {
startPassiveEffectTimer();
destroy();
} finally {
recordPassiveEffectDuration(fiber);
}
} else {
destroy();
}
}
}
/**
* Execute the create function of the effect.
*/
const mountEffects = pendingPassiveHookEffectsMount;
pendingPassiveHookEffectsMount = [];
for (let i = 0; i < mountEffects.length; i += 2) {
const effect = ((mountEffects[i]: any): HookEffect);
const fiber = ((mountEffects[i + 1]: any): Fiber);
const create = effect.create;
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
fiber.mode & ProfileMode
) {
try {
startPassiveEffectTimer();
/**
* The return value of create is destroy. If this effect object is collected again due to an update,
* it will be pointed to during the next flushPassiveEffectsImpl.
*/
effect.destroy = create();
} finally {
recordPassiveEffectDuration(fiber);
}
} else {
effect.destroy = create();
}
}
...
return true;
}
Conclusion#
useEffect
is an asynchronous operation, and the operation to clear side effects (flushPassiveEffectsImpl
) will be registered as a Task.- During the layout phase, effects will be collected into the execution array, but scheduling occurs during the before Mutation scheduling.
- The scheduling priority of the operation to clear side effects (
flushPassiveEffectsImpl
) is not high, so it may be preempted by higher-priority tasks during scheduling. - During mounting, only the create function will be executed; during updating, the currentHook's destroy will be executed first, followed by the newHook's create.
About the Timing of useLayoutEffect Execution#
useLayoutEffect
, aside from its triggering timing, behaves similarly to useEffect
.
At this point, we set a breakpoint on the create function of useLayoutEffect
to observe its call stack.
At this point, we set a breakpoint on commitHookEffectListMount
and find that all operations are synchronous. (commitLayoutEffects is the entry point for layout)
function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
// The passed tag is HookLayout | HookHasEffect, so we can filter out useLayoutEffect's effects.
if ((effect.tag & tag) === tag) {
// Mount
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect); // Loop once.
}
}
If we set a breakpoint on the destroy function of useLayoutEffect
.
At this point, we set a breakpoint on commitHookEffectListUnmount
and find that all operations are synchronous. (commitMutationEffects is the entry point for mutation)
function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
// Unmount
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
Conclusion#
useLayoutEffect
is called synchronously, with its destroy called during the mutation phase and its create called during the layout phase.