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?
jsx
function 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);}
jsx
function 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
.
js
function 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;}
js
function 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.
js
const effect: Effect = {tag,create,destroy,deps,// Circularnext: (null: any),};
js
const 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
js
function 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);}}
js
function 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.
js
function 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);}}
js
function 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!