How does useInsertionEffect() work internally in React?

useInsertionEffect() allows us to run some effects before the DOM mutation is done, since Layout Effects are after DOM mutation, so this is the earliest timing we can get from effect hooks. Let’s try to figure out what is under the hood for useInsertionEffect().

1. The internals of useInsertionEffect()

js
useInsertionEffect(() => {
// your code here
}, [deps])
js
useInsertionEffect(() => {
// your code here
}, [deps])

These are the implementation of useInsertionEffect() on initial render and re-renders.

js
function mountInsertionEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(UpdateEffect, HookInsertion, create, deps);
}
function updateInsertionEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(UpdateEffect, HookInsertion, create, deps);
}
js
function mountInsertionEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(UpdateEffect, HookInsertion, create, deps);
}
function updateInsertionEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(UpdateEffect, HookInsertion, create, deps);
}

We see that internally it uses mountEffectImpl() and updateEffectImpl() which are the same for useEffect() and useLayoutEffect(), except that they have different tag - HookInsertion to tell the difference.

export const Insertion = /* */ 0b0010;
export const Layout = /* */ 0b0100;
export const Passive = /* */ 0b1000;
export const Insertion = /* */ 0b0010;
export const Layout = /* */ 0b0100;
export const Passive = /* */ 0b1000;

1.1. Let’s look at commitRoot() again

commitRoot() is the step after reconciliation, it contains the flushing of DOM mutation and effects.

function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
// If there are pending passive effects, schedule a callback to process them.
// Do this as early as possible, so it is queued before anything else that
// might get scheduled in the commit phase. (See #16714.)
// TODO: Delete all other places that schedule the passive effect callback
// They're redundant.
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});

Here it flushes the passive effects created by useEffect()

This schedules the flushing in next tick, not right away

Detail explained in The lifecycle of effect hooks in React

}
}
root.finishedWork = null;
root.finishedLanes = NoLanes;
// Check if there are any effects in the whole tree.
// TODO: This is left over from the effect list implementation, where we had
// to check for the existence of `firstEffect` to satisfy Flow. I think the
// only other reason this optimization exists is because it affects profiling.
// Reconsider whether this is necessary.
const subtreeHasEffects =
(finishedWork.subtreeFlags &
(BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
NoFlags;
const rootHasEffect =
(finishedWork.flags &
(BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
NoFlags;
if (subtreeHasEffects || rootHasEffect) {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = null;
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(DiscreteEventPriority);
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// Reset this to null before calling lifecycles
ReactCurrentOwner.current = null;
// The commit phase is broken into several sub-phases. We do a separate pass
// of the effect list for each phase: all mutation effects come before all
// layout effects, and so on.
// The first phase a "before mutation" phase. We use this phase to read the
// state of the host tree right before we mutate it. This is where
// getSnapshotBeforeUpdate is called.
const shouldFireAfterActiveInstanceBlur = commitBeforeMutationEffects(
root,
finishedWork,
);
// The next phase is the mutation phase, where we mutate the host tree.
commitMutationEffects(root, finishedWork, lanes);

Here the diffing result of fiber tree are reflected to host DOM

// 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;
commitLayoutEffects(finishedWork, root, lanes);

Layout Effects are run here.

// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
requestPaint();
executionContext = prevExecutionContext;
// Reset the priority to the previous non-sync value.
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig.transition = prevTransition;
} else {
// No effects.
root.current = finishedWork;
}
...
}
function commitRootImpl(
root: FiberRoot,
recoverableErrors: null | Array<CapturedValue<mixed>>,
transitions: Array<Transition> | null,
renderPriorityLevel: EventPriority,
) {
// If there are pending passive effects, schedule a callback to process them.
// Do this as early as possible, so it is queued before anything else that
// might get scheduled in the commit phase. (See #16714.)
// TODO: Delete all other places that schedule the passive effect callback
// They're redundant.
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});

Here it flushes the passive effects created by useEffect()

This schedules the flushing in next tick, not right away

Detail explained in The lifecycle of effect hooks in React

}
}
root.finishedWork = null;
root.finishedLanes = NoLanes;
// Check if there are any effects in the whole tree.
// TODO: This is left over from the effect list implementation, where we had
// to check for the existence of `firstEffect` to satisfy Flow. I think the
// only other reason this optimization exists is because it affects profiling.
// Reconsider whether this is necessary.
const subtreeHasEffects =
(finishedWork.subtreeFlags &
(BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
NoFlags;
const rootHasEffect =
(finishedWork.flags &
(BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
NoFlags;
if (subtreeHasEffects || rootHasEffect) {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = null;
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(DiscreteEventPriority);
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// Reset this to null before calling lifecycles
ReactCurrentOwner.current = null;
// The commit phase is broken into several sub-phases. We do a separate pass
// of the effect list for each phase: all mutation effects come before all
// layout effects, and so on.
// The first phase a "before mutation" phase. We use this phase to read the
// state of the host tree right before we mutate it. This is where
// getSnapshotBeforeUpdate is called.
const shouldFireAfterActiveInstanceBlur = commitBeforeMutationEffects(
root,
finishedWork,
);
// The next phase is the mutation phase, where we mutate the host tree.
commitMutationEffects(root, finishedWork, lanes);

Here the diffing result of fiber tree are reflected to host DOM

// 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;
commitLayoutEffects(finishedWork, root, lanes);

Layout Effects are run here.

// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
requestPaint();
executionContext = prevExecutionContext;
// Reset the priority to the previous non-sync value.
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig.transition = prevTransition;
} else {
// No effects.
root.current = finishedWork;
}
...
}

We see the explicit flussing of Layout Effects and Passive Effects, but we don’t see the processing of insertion effects here!! Actually they are handled inside of commitMutationEffects().

1.2 Insertion Effects are run right before DOM mutation happens

export function commitMutationEffects(
root: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
) {
inProgressLanes = committedLanes;
inProgressRoot = root;
commitMutationEffectsOnFiber(finishedWork, root, committedLanes);
inProgressLanes = null;
inProgressRoot = null;
}
function commitMutationEffectsOnFiber(

This function handles the mutation for single fiber

finishedWork: Fiber,
root: FiberRoot,
lanes: Lanes,
) {
const current = finishedWork.alternate;
const flags = finishedWork.flags;
// The effect flag should be checked *after* we refine the type of fiber,
// because the fiber tag is more specific. An exception is any flag related
// to reconciliation, because those can be set on all fiber types.
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
recursivelyTraverseMutationEffects(root, finishedWork, lanes);

Notice that for mutation, we need to process the child first

For example, if we create new DOM nodes, doing so allows us to

put child nodes as children of paret node

commitReconciliationEffects(finishedWork);
if (flags & Update) {
try {
commitHookEffectListUnmount(
HookInsertion | HookHasEffect,
finishedWork,
finishedWork.return,
);
commitHookEffectListMount(
HookInsertion | HookHasEffect,
finishedWork,
);

Insertion Effects are commited here!

For details of these two functions, refer to The lifecycle of effect hooks in React

} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
try {
commitHookEffectListUnmount(
HookLayout | HookHasEffect,
finishedWork,
finishedWork.return,
);

The cleanup of Layout Effects are handled here

} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
return;
}
...
}
}
export function commitMutationEffects(
root: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
) {
inProgressLanes = committedLanes;
inProgressRoot = root;
commitMutationEffectsOnFiber(finishedWork, root, committedLanes);
inProgressLanes = null;
inProgressRoot = null;
}
function commitMutationEffectsOnFiber(

This function handles the mutation for single fiber

finishedWork: Fiber,
root: FiberRoot,
lanes: Lanes,
) {
const current = finishedWork.alternate;
const flags = finishedWork.flags;
// The effect flag should be checked *after* we refine the type of fiber,
// because the fiber tag is more specific. An exception is any flag related
// to reconciliation, because those can be set on all fiber types.
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
recursivelyTraverseMutationEffects(root, finishedWork, lanes);

Notice that for mutation, we need to process the child first

For example, if we create new DOM nodes, doing so allows us to

put child nodes as children of paret node

commitReconciliationEffects(finishedWork);
if (flags & Update) {
try {
commitHookEffectListUnmount(
HookInsertion | HookHasEffect,
finishedWork,
finishedWork.return,
);
commitHookEffectListMount(
HookInsertion | HookHasEffect,
finishedWork,
);

Insertion Effects are commited here!

For details of these two functions, refer to The lifecycle of effect hooks in React

} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
try {
commitHookEffectListUnmount(
HookLayout | HookHasEffect,
finishedWork,
finishedWork.return,
);

The cleanup of Layout Effects are handled here

} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
return;
}
...
}
}

The cleanup also happens in deleted fibers.

function commitDeletionEffectsOnFiber(
finishedRoot: FiberRoot,
nearestMountedAncestor: Fiber,
deletedFiber: Fiber,
) {
onCommitUnmount(deletedFiber);
// The cases in this outer switch modify the stack before they traverse
// into their subtree. There are simpler cases in the inner switch
// that don't modify the stack.
switch (deletedFiber.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
if (!offscreenSubtreeWasHidden) {
const updateQueue: FunctionComponentUpdateQueue | null =
(deletedFiber.updateQueue: any);
if (updateQueue !== null) {
const lastEffect = updateQueue.lastEffect;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const tag = effect.tag;
const inst = effect.inst;
const destroy = inst.destroy;
if (destroy !== undefined) {
if ((tag & HookInsertion) !== NoHookEffect) {
inst.destroy = undefined;
safelyCallDestroy(
deletedFiber,
nearestMountedAncestor,
destroy,
);

By definition, cleanup of Insertion Effects are needed when component is unmounted

It does the same thing as commitHookEffectListUnmount()

Below is the Layout Effects

} else if ((tag & HookLayout) !== NoHookEffect) {
if (enableSchedulingProfiler) {
markComponentLayoutEffectUnmountStarted(deletedFiber);
}
if (shouldProfile(deletedFiber)) {
startLayoutEffectTimer();
inst.destroy = undefined;
safelyCallDestroy(
deletedFiber,
nearestMountedAncestor,
destroy,
);
recordLayoutEffectDuration(deletedFiber);
} else {
inst.destroy = undefined;
safelyCallDestroy(
deletedFiber,
nearestMountedAncestor,
destroy,
);
}
if (enableSchedulingProfiler) {
markComponentLayoutEffectUnmountStopped();
}
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
}
recursivelyTraverseDeletionEffects(
finishedRoot,
nearestMountedAncestor,
deletedFiber,
);
return;
}
...
}
}
function commitDeletionEffectsOnFiber(
finishedRoot: FiberRoot,
nearestMountedAncestor: Fiber,
deletedFiber: Fiber,
) {
onCommitUnmount(deletedFiber);
// The cases in this outer switch modify the stack before they traverse
// into their subtree. There are simpler cases in the inner switch
// that don't modify the stack.
switch (deletedFiber.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent: {
if (!offscreenSubtreeWasHidden) {
const updateQueue: FunctionComponentUpdateQueue | null =
(deletedFiber.updateQueue: any);
if (updateQueue !== null) {
const lastEffect = updateQueue.lastEffect;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const tag = effect.tag;
const inst = effect.inst;
const destroy = inst.destroy;
if (destroy !== undefined) {
if ((tag & HookInsertion) !== NoHookEffect) {
inst.destroy = undefined;
safelyCallDestroy(
deletedFiber,
nearestMountedAncestor,
destroy,
);

By definition, cleanup of Insertion Effects are needed when component is unmounted

It does the same thing as commitHookEffectListUnmount()

Below is the Layout Effects

} else if ((tag & HookLayout) !== NoHookEffect) {
if (enableSchedulingProfiler) {
markComponentLayoutEffectUnmountStarted(deletedFiber);
}
if (shouldProfile(deletedFiber)) {
startLayoutEffectTimer();
inst.destroy = undefined;
safelyCallDestroy(
deletedFiber,
nearestMountedAncestor,
destroy,
);
recordLayoutEffectDuration(deletedFiber);
} else {
inst.destroy = undefined;
safelyCallDestroy(
deletedFiber,
nearestMountedAncestor,
destroy,
);
}
if (enableSchedulingProfiler) {
markComponentLayoutEffectUnmountStopped();
}
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
}
recursivelyTraverseDeletionEffects(
finishedRoot,
nearestMountedAncestor,
deletedFiber,
);
return;
}
...
}
}

We’ve already touched commitHookEffectListMount() before in The lifecycle of effect hooks in React, for more details please refer there.

2. Summary

There are 3 kinds of Effects we can create now. In commit phase, the ordering of effects are.

  1. Insertion Effects - useInsertionEffect()
  2. Mutation Effects - host DOM updates diffed from reconciliation.
  3. Layout Effects - useLayoutEffect()
  4. Passive Effects - useEffect()

It is synchronous from 1 to 3, the last step of Passive Effects are run in next tick.

3. React Quizzes

To test your understanding, you can give it a try at following quiz.

And here is another one.

Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!

😳 Would you like to share my post to more people ?    

❮ Prev: How does useState() work internally in React?

Next: How does useEffect() work internally in React?