How does useLayoutEffect() work internally?
useLayoutEffect() offers a chance to run some code synchronously right after DOM is updated, letās figure out how it works.
For most of the hooks, internally there are 2 implementations: mountXXX() and updateXXX().
1. how layout effects are mounted?
jsxfunction mountLayoutEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null): void {let fiberFlags: Flags = UpdateEffect;if (enableSuspenseLayoutEffectSemantics) {fiberFlags |= LayoutStaticEffect;}return mountEffectImpl(fiberFlags, HookLayout, create, deps);}function updateLayoutEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null): void {return updateEffectImpl(UpdateEffect, HookLayout, create, deps);}
jsxfunction mountLayoutEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null): void {let fiberFlags: Flags = UpdateEffect;if (enableSuspenseLayoutEffectSemantics) {fiberFlags |= LayoutStaticEffect;}return mountEffectImpl(fiberFlags, HookLayout, create, deps);}function updateLayoutEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null): void {return updateEffectImpl(UpdateEffect, HookLayout, create, deps);}
In turn mountEffectImpl() and updateEffectImpl() are used, which are the same functions for useEffect(), the different is the second argument - hookFlags
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = mountWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,undefined,nextDeps);}function mountWorkInProgressHook(): Hook {const hook: Hook = {memoizedState: null,baseState: null,baseQueue: null,queue: null,next: null,};if (workInProgressHook === null) {currentlyRenderingFiber.memoizedState = workInProgressHook = hook;This is the first hook in the list
} else {workInProgressHook = workInProgressHook.next = hook;Append to the end of the list
}return workInProgressHook;}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = mountWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,undefined,nextDeps);}function mountWorkInProgressHook(): Hook {const hook: Hook = {memoizedState: null,baseState: null,baseQueue: null,queue: null,next: null,};if (workInProgressHook === null) {currentlyRenderingFiber.memoizedState = workInProgressHook = hook;This is the first hook in the list
} else {workInProgressHook = workInProgressHook.next = hook;Append to the end of the list
}return workInProgressHook;}
mountWorkInProgressHook() creates a new empty hook and appends to the hook list of this fiber. pushEffect() creates an effect object on fiberās updateQueue, they are connected by memoizedState.
jsfunction pushEffect(tag, create, destroy, deps) {const effect: Effect = {tag,create,destroy,deps,// Circularnext: (null: any),};let componentUpdateQueue: null | FunctionComponentUpdateQueue =(currentlyRenderingFiber.updateQueue: any);if (componentUpdateQueue === null) {componentUpdateQueue = createFunctionComponentUpdateQueue();currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);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;}
jsfunction pushEffect(tag, create, destroy, deps) {const effect: Effect = {tag,create,destroy,deps,// Circularnext: (null: any),};let componentUpdateQueue: null | FunctionComponentUpdateQueue =(currentlyRenderingFiber.updateQueue: any);if (componentUpdateQueue === null) {componentUpdateQueue = createFunctionComponentUpdateQueue();currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);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;}
Notice that not all hooks are effect hooks, for example useState() is not. Effect means side effects, which are supposed to run, I guess thatās why they are placed in updateQueue, since I donāt see why there could not be a different place.
The tag property on Effect is important, for case of mounting layout effects, it is HookHasEffect | HookLayout.
Thatās all for mounting, it sets up updateQueue and creates a new hook for it.
2. how layout effects are updated?
updateLayoutEffect() would be something similar.
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = updateWorkInProgressHook();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)) {hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);return;}}}currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,-------------create,destroy,nextDeps);}
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = updateWorkInProgressHook();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)) {hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);return;}}}currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,-------------create,destroy,nextDeps);}
Functions are run in the reconciliation phase where new fiber tree is constructed. current is prefix for existing stuff, updateWorkInProgressHook() will move the currentWork forward and also returns the version being created.
We can see there is change detection by areHookInputsEqual(nextDeps, prevDeps), either the props changes or not, memoizedState is set with the effect, but the flags are different, if there is no change, then HookHasEffect is not added.
Thus HookHasEffect is important, it indicates that the effect needs to be run or not.
3. when do layout effects actually get run?
Good question, for an effect, either passive effect by useEffect() or layout effect, the function passed in is actually the create function, the closure returned by create function is the destroy (cleanup) function.
jsconst effect: Effect = {tag,create,destroy,deps,// Circularnext: (null: any),};
jsconst effect: Effect = {tag,create,destroy,deps,// Circularnext: (null: any),};
Above is the structure of Effect, we can see the create and destroy.
Since effects are run after DOM mutation, so they must be in commit phase, below is where it starts.
function commitRootImpl(root: FiberRoot,recoverableErrors: null | Array<mixed>,transitions: Array<Transition> | null,renderPriorityLevel: EventPriority,) {....// The next phase is the mutation phase, where we mutate the host tree.commitMutationEffects(root, finishedWork, lanes);commitLayoutEffects(finishedWork, root, lanes);...}
function commitRootImpl(root: FiberRoot,recoverableErrors: null | Array<mixed>,transitions: Array<Transition> | null,renderPriorityLevel: EventPriority,) {....// The next phase is the mutation phase, where we mutate the host tree.commitMutationEffects(root, finishedWork, lanes);commitLayoutEffects(finishedWork, root, lanes);...}
commitLayoutEffects() will try to run all the pending layout effects. It again is sort of traversing through the tree, so the same traversal algorithm is used, letās look at the completing phase.
function commitLayoutMountEffects_complete(subtreeRoot: Fiber,root: FiberRoot,committedLanes: Lanes) {while (nextEffect !== null) {const fiber = nextEffect;if ((fiber.flags & LayoutMask) !== NoFlags) {const current = fiber.alternate;setCurrentDebugFiberInDEV(fiber);try {commitLayoutEffectOnFiber(root, current, fiber, committedLanes);} catch (error) {captureCommitPhaseError(fiber, fiber.return, error);}resetCurrentDebugFiberInDEV();}if (fiber === subtreeRoot) {nextEffect = null;return;}const sibling = fiber.sibling;if (sibling !== null) {sibling.return = fiber.return;nextEffect = sibling;return;}nextEffect = fiber.return;}}
function commitLayoutMountEffects_complete(subtreeRoot: Fiber,root: FiberRoot,committedLanes: Lanes) {while (nextEffect !== null) {const fiber = nextEffect;if ((fiber.flags & LayoutMask) !== NoFlags) {const current = fiber.alternate;setCurrentDebugFiberInDEV(fiber);try {commitLayoutEffectOnFiber(root, current, fiber, committedLanes);} catch (error) {captureCommitPhaseError(fiber, fiber.return, error);}resetCurrentDebugFiberInDEV();}if (fiber === subtreeRoot) {nextEffect = null;return;}const sibling = fiber.sibling;if (sibling !== null) {sibling.return = fiber.return;nextEffect = sibling;return;}nextEffect = fiber.return;}}
It basically checks if there is layout effect needs to be run on this fiber by commitLayoutEffectOnFiber(), and goes to sibling fiber or parent fiber.
function commitLayoutEffectOnFiber(finishedRoot: FiberRoot,current: Fiber | null,finishedWork: Fiber,committedLanes: Lanes,): void {if ((finishedWork.flags & LayoutMask) !== NoFlags) {switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {if (!enableSuspenseLayoutEffectSemantics ||!offscreenSubtreeWasHidden) {// At this point layout effects have already been destroyed (during mutation phase).// This is done to prevent sibling component effects from interfering with each other,// e.g. a destroy function in one component should never override a ref set// by a create function in another component during the same commit.if (enableProfilerTimer &&enableProfilerCommitHooks &&finishedWork.mode & ProfileMode) {try {startLayoutEffectTimer();commitHookEffectListMount(HookLayout | HookHasEffect,finishedWork,);} finally {recordLayoutEffectDuration(finishedWork);}} else {commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);}}break;}...
function commitLayoutEffectOnFiber(finishedRoot: FiberRoot,current: Fiber | null,finishedWork: Fiber,committedLanes: Lanes,): void {if ((finishedWork.flags & LayoutMask) !== NoFlags) {switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {if (!enableSuspenseLayoutEffectSemantics ||!offscreenSubtreeWasHidden) {// At this point layout effects have already been destroyed (during mutation phase).// This is done to prevent sibling component effects from interfering with each other,// e.g. a destroy function in one component should never override a ref set// by a create function in another component during the same commit.if (enableProfilerTimer &&enableProfilerCommitHooks &&finishedWork.mode & ProfileMode) {try {startLayoutEffectTimer();commitHookEffectListMount(HookLayout | HookHasEffect,finishedWork,);} finally {recordLayoutEffectDuration(finishedWork);}} else {commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);}}break;}...
We can find commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork), which means
HookLayout-> this effect is layout effectHookHasEffect-> this effect needs to be run
jsfunction commitHookEffectListMount(flags: HookFlags, 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 & flags) === flags) {// Mountconst create = effect.create;effect.destroy = create();}effect = effect.next;} while (effect !== firstEffect);}}
jsfunction commitHookEffectListMount(flags: HookFlags, 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 & flags) === flags) {// Mountconst create = effect.create;effect.destroy = create();}effect = effect.next;} while (effect !== firstEffect);}}
commitHookEffectListMount() is straightforward, it checks against effect tag and run the create() function and set up the destroy.
4. when do layout effects get cleaned up?
Weāve seen how the cleanup function destory is set up, when do they get run? It actually happens before it in commitMutationEffects().
function commitMutationEffectsOnFiber(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 reconcilation, because those can be set on all fiber types.switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case MemoComponent:case SimpleMemoComponent: {recursivelyTraverseMutationEffects(root, finishedWork, lanes);Pay attention to this call, it is important
commitReconciliationEffects(finishedWork);if (flags & Update) {try {commitHookEffectListUnmount(HookInsertion | HookHasEffect,finishedWork,finishedWork.return,);commitHookEffectListMount(HookInsertion | HookHasEffect,finishedWork,);} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}// Layout effects are destroyed during the mutation phase so that all// destroy functions for all fibers are called before any create functions.// This prevents sibling component effects from interfering with each other,// e.g. a destroy function in one component should never override a ref set// by a create function in another component during the same commit.if (enableProfilerTimer &&enableProfilerCommitHooks &&finishedWork.mode & ProfileMode) {try {startLayoutEffectTimer();commitHookEffectListUnmount(HookLayout | HookHasEffect,finishedWork,finishedWork.return,);} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}recordLayoutEffectDuration(finishedWork);} else {try {commitHookEffectListUnmount(HookLayout | HookHasEffect,finishedWork,finishedWork.return,);} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}}}return;}...
function commitMutationEffectsOnFiber(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 reconcilation, because those can be set on all fiber types.switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case MemoComponent:case SimpleMemoComponent: {recursivelyTraverseMutationEffects(root, finishedWork, lanes);Pay attention to this call, it is important
commitReconciliationEffects(finishedWork);if (flags & Update) {try {commitHookEffectListUnmount(HookInsertion | HookHasEffect,finishedWork,finishedWork.return,);commitHookEffectListMount(HookInsertion | HookHasEffect,finishedWork,);} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}// Layout effects are destroyed during the mutation phase so that all// destroy functions for all fibers are called before any create functions.// This prevents sibling component effects from interfering with each other,// e.g. a destroy function in one component should never override a ref set// by a create function in another component during the same commit.if (enableProfilerTimer &&enableProfilerCommitHooks &&finishedWork.mode & ProfileMode) {try {startLayoutEffectTimer();commitHookEffectListUnmount(HookLayout | HookHasEffect,finishedWork,finishedWork.return,);} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}recordLayoutEffectDuration(finishedWork);} else {try {commitHookEffectListUnmount(HookLayout | HookHasEffect,finishedWork,finishedWork.return,);} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}}}return;}...
See the comments in the middle, commitHookEffectListUnmount() is responsible to run cleanups.
jsfunction commitHookEffectListUnmount(flags: HookFlags,finishedWork: Fiber,nearestMountedAncestor: Fiber | null) {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 & flags) === flags) {// Unmountconst destroy = effect.destroy;effect.destroy = undefined;if (destroy !== undefined) {safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);}}effect = effect.next;} while (effect !== firstEffect);}}
jsfunction commitHookEffectListUnmount(flags: HookFlags,finishedWork: Fiber,nearestMountedAncestor: Fiber | null) {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 & flags) === flags) {// Unmountconst destroy = effect.destroy;effect.destroy = undefined;if (destroy !== undefined) {safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);}}effect = effect.next;} while (effect !== firstEffect);}}
commitHookEffectListUnmount() is also straightforward, it get the destory function on effect, and then just run it.
Rememeber when deps changes, the effect is set with an effect of HookHasEffect.
5. but how cleanups get run when component is unmounted?
Good question, other than deps change that triggers the cleanup, component unmount also leads to cleanup, which in fiber world means fiber removal.
Notice there is recursivelyTraverseMutationEffects() in above sinppet, actually for cases of fiber deletion, effects are cleaned up there.
function recursivelyTraverseMutationEffects(root: FiberRoot,parentFiber: Fiber,lanes: Lanes) {// Deletions effects can be scheduled on any fiber type. They need to happen// before the children effects hae fired.const deletions = parentFiber.deletions;deleted fibers are stored on parentFiber
if (deletions !== null) {for (let i = 0; i < deletions.length; i++) {const childToDelete = deletions[i];try {commitDeletionEffects(root, parentFiber, childToDelete);} catch (error) {captureCommitPhaseError(childToDelete, parentFiber, error);}}}const prevDebugFiber = getCurrentDebugFiberInDEV();if (parentFiber.subtreeFlags & MutationMask) {let child = parentFiber.child;while (child !== null) {setCurrentDebugFiberInDEV(child);commitMutationEffectsOnFiber(child, root, lanes);child = child.sibling;}}setCurrentDebugFiberInDEV(prevDebugFiber);}
function recursivelyTraverseMutationEffects(root: FiberRoot,parentFiber: Fiber,lanes: Lanes) {// Deletions effects can be scheduled on any fiber type. They need to happen// before the children effects hae fired.const deletions = parentFiber.deletions;deleted fibers are stored on parentFiber
if (deletions !== null) {for (let i = 0; i < deletions.length; i++) {const childToDelete = deletions[i];try {commitDeletionEffects(root, parentFiber, childToDelete);} catch (error) {captureCommitPhaseError(childToDelete, parentFiber, error);}}}const prevDebugFiber = getCurrentDebugFiberInDEV();if (parentFiber.subtreeFlags & MutationMask) {let child = parentFiber.child;while (child !== null) {setCurrentDebugFiberInDEV(child);commitMutationEffectsOnFiber(child, root, lanes);child = child.sibling;}}setCurrentDebugFiberInDEV(prevDebugFiber);}
We can see that it traverse through all the descendent fiber from fiber.deletions and run the cleanup functions.
Since deleted fibers are not in the final fiber tree, they are put into their parent fiber for reference.
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {if (!shouldTrackSideEffects) {// Noop.return;}const deletions = returnFiber.deletions;if (deletions === null) {returnFiber.deletions = [childToDelete];returnFiber.flags |= ChildDeletion;} else {deletions.push(childToDelete);}}
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {if (!shouldTrackSideEffects) {// Noop.return;}const deletions = returnFiber.deletions;if (deletions === null) {returnFiber.deletions = [childToDelete];returnFiber.flags |= ChildDeletion;} else {deletions.push(childToDelete);}}
And deletions are set during reconciliation before commiting.
Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!
