The lifecycle of effect hooks in React
Iâve talked about how useEffect() works in this video,
but the content was a bit messy.
Also the React version I used was not the latest,
things have changed since then so let me explain the lifecycle of Effect hooks again.
By âEffect hookâ, I mean âuseEffect()â, like below.
jsfunction A() {useEffect(function create() {console.log("create effect");return function cleanup() {console.log("destroy effect");};}, []);return <div />;}
jsfunction A() {useEffect(function create() {console.log("create effect");return function cleanup() {console.log("destroy effect");};}, []);return <div />;}
I give the functions names of create() and cleanup() to differentiate.
Letâs answer these 3 questions.
- what happens when
useEffect()is first called? - what happens when
depsinuseEffect()change? - when do
cleanup()get invoked?
What happens when useEffect() is first called ?
useEffect() is a way to create Effect hook, a hook is something attached to a fiber.
From source code, we see that useEffect() resolves to mountEffect for first call, and updateEffect for latter updates.
Letâs see what is in mountEffect().
jsfunction 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) {// This is the first hook in the listcurrentlyRenderingFiber.memoizedState = workInProgressHook = hook;} else {// Append to the end of the listworkInProgressHook = workInProgressHook.next = hook;}return workInProgressHook;}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;}
jsfunction 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) {// This is the first hook in the listcurrentlyRenderingFiber.memoizedState = workInProgressHook = hook;} else {// Append to the end of the listworkInProgressHook = workInProgressHook.next = hook;}return workInProgressHook;}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;}
Looks a lot but actually pretty simple, it basically does 2 things:
- create a new hook in
mountWorkInProgressHook()which is attached to hook list (memoizedState) of the fiber - set up an update effect with our creator function, and attach to the
updateQueueon fiber, and also track the effect in the hook throughmemoizedState, our creator function is not yet invoked.
So a fiber could have:
- a list up updates (effects), in
updateQueue - a list of hooks in
memoizedState, for effect hook, it tracks the effects as well.
memoizedState is used. Also useEffect() pushs an effect in updateQueue, so it is called Effect hook. Effect.tag is to mark if effect should be run
Effect means side effects, put in updateQueue on fiber, they will be run after React commits the changes.
See the first argument of pushEffect? It is to control Effect.tag, in our mounting phase, it is passed with HookHasEffect | hookFlags, in which HookHasEffect means it should be run.
This is very important, we could infer that in updateEffect, this flag is going to be toggled by checking if deps has changed.
flushPassiveEffects()
As weâve talked in previous videos, flushPassiveEffects() is the function to run those effects created by useEffect().
It is invoked in a few places, but the most important one is in commitRoot(), which is the commit phase after reconciliation.
jsif ((finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||(finishedWork.flags & PassiveMask) !== NoFlags) {if (!rootDoesHavePassiveEffects) {rootDoesHavePassiveEffects = true;pendingPassiveEffectsRemainingLanes = remainingLanes;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;});}}
jsif ((finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||(finishedWork.flags & PassiveMask) !== NoFlags) {if (!rootDoesHavePassiveEffects) {rootDoesHavePassiveEffects = true;pendingPassiveEffectsRemainingLanes = remainingLanes;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;});}}
flushPassiveEffects() is scheduled by schedulCallback, so it is run in next tick, not synchronously right after DOM changes. scheduler is not our topic today, letâs look into details of flushPassiveEffects().
from the source code, we can see that basically id does 2 things.
jscommitPassiveUnmountEffects(root.current);commitPassiveMountEffects(root, root.current);
jscommitPassiveUnmountEffects(root.current);commitPassiveMountEffects(root, root.current);
An effectâs cleanup must be run first before it is run again, so unmount happens first before mount.
These 2 functions checks effects on all fibers from root, the algorithm is what we covered in previous post
You might think that wouldnât it be inefficient to traverse through tree over and over again ? You are right, that is why React has this optimization of avoiding unnecessary checks with finishedWork.subtreeFlags finishedWork.flags, .etc.
commitPassiveUnmountEffects()
As our algorithm in last post explained, commitPassiveUnmountEffects() includes a begin() and complete().
jsfunction commitPassiveUnmountEffects_begin() {while (nextEffect !== null) {const fiber = nextEffect;const child = fiber.child;if ((nextEffect.flags & ChildDeletion) !== NoFlags) {const deletions = fiber.deletions;if (deletions !== null) {for (let i = 0; i < deletions.length; i++) {const fiberToDelete = deletions[i];nextEffect = fiberToDelete;commitPassiveUnmountEffectsInsideOfDeletedTree_begin(fiberToDelete,fiber);}if (deletedTreeCleanUpLevel >= 1) {// A fiber was deleted from this parent fiber, but it's still part of// the previous (alternate) parent fiber's list of children. Because// children are a linked list, an earlier sibling that's still alive// will be connected to the deleted fiber via its `alternate`://// live fiber// --alternate--> previous live fiber// --sibling--> deleted fiber//// We can't disconnect `alternate` on nodes that haven't been deleted// yet, but we can disconnect the `sibling` and `child` pointers.const previousFiber = fiber.alternate;if (previousFiber !== null) {let detachedChild = previousFiber.child;if (detachedChild !== null) {previousFiber.child = null;do {const detachedSibling = detachedChild.sibling;detachedChild.sibling = null;detachedChild = detachedSibling;} while (detachedChild !== null);}}}nextEffect = fiber;}}if ((fiber.subtreeFlags & PassiveMask) !== NoFlags && child !== null) {ensureCorrectReturnPointer(child, fiber);nextEffect = child;} else {commitPassiveUnmountEffects_complete();}}}
jsfunction commitPassiveUnmountEffects_begin() {while (nextEffect !== null) {const fiber = nextEffect;const child = fiber.child;if ((nextEffect.flags & ChildDeletion) !== NoFlags) {const deletions = fiber.deletions;if (deletions !== null) {for (let i = 0; i < deletions.length; i++) {const fiberToDelete = deletions[i];nextEffect = fiberToDelete;commitPassiveUnmountEffectsInsideOfDeletedTree_begin(fiberToDelete,fiber);}if (deletedTreeCleanUpLevel >= 1) {// A fiber was deleted from this parent fiber, but it's still part of// the previous (alternate) parent fiber's list of children. Because// children are a linked list, an earlier sibling that's still alive// will be connected to the deleted fiber via its `alternate`://// live fiber// --alternate--> previous live fiber// --sibling--> deleted fiber//// We can't disconnect `alternate` on nodes that haven't been deleted// yet, but we can disconnect the `sibling` and `child` pointers.const previousFiber = fiber.alternate;if (previousFiber !== null) {let detachedChild = previousFiber.child;if (detachedChild !== null) {previousFiber.child = null;do {const detachedSibling = detachedChild.sibling;detachedChild.sibling = null;detachedChild = detachedSibling;} while (detachedChild !== null);}}}nextEffect = fiber;}}if ((fiber.subtreeFlags & PassiveMask) !== NoFlags && child !== null) {ensureCorrectReturnPointer(child, fiber);nextEffect = child;} else {commitPassiveUnmountEffects_complete();}}}
It basically does one thing, that is to cleanup the effects in deleted fibers.
Why?
Because if some fibers are deleted, it is not on our fiber tree anymore, in order to get reference to them to do some cleanups, React keep track of them on their parent fiber through deletions. Weâll cover it in details in the future, but anyway, we know how React does it now.
jsfunction commitPassiveUnmountEffects_complete() {while (nextEffect !== null) {const fiber = nextEffect;if ((fiber.flags & Passive) !== NoFlags) {commitPassiveUnmountOnFiber(fiber);}const sibling = fiber.sibling;if (sibling !== null) {ensureCorrectReturnPointer(sibling, fiber.return);nextEffect = sibling;return;}nextEffect = fiber.return;}}function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {commitHookEffectListUnmount(HookPassive | HookHasEffect,finishedWork,finishedWork.return);break;}}}
jsfunction commitPassiveUnmountEffects_complete() {while (nextEffect !== null) {const fiber = nextEffect;if ((fiber.flags & Passive) !== NoFlags) {commitPassiveUnmountOnFiber(fiber);}const sibling = fiber.sibling;if (sibling !== null) {ensureCorrectReturnPointer(sibling, fiber.return);nextEffect = sibling;return;}nextEffect = fiber.return;}}function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {commitHookEffectListUnmount(HookPassive | HookHasEffect,finishedWork,finishedWork.return);break;}}}
In complete(), commitHookEffectListUnmount() is the core logic, the first argument is HookPassive | HookHasEffect, meaning we should rerun passive effects which needs to be run, as we explained what effect.tag does at the beginning.
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);}}
The code is fairly simple, it loop through all the linked effects, check the tag to see if matches HookPassive | HookHasEffect, and run the destroy.
But when we created the effect hook pushEffect(HookHasEffect | hookFlags, create, undefined,nextDeps), we pass in undefined as destroy.
So when does destroy gets set ? I bet you already know it, when we pushEffect, it is not mounted yet, destroy must be created in commitPassiveMountEffects().
commitPassiveMountEffects()
The tree traversal algorithm is the same, weâll skip it.
Similarly, it triggers commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork).
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);}}
Simple, it set up the destroy by effect.destroy = create(), which means our creator function gots run here. Finally!!
Phew, that is a long journey! Stay with us we have 2 more questions to go.
What happens when deps in useEffect() change.
After first mount, we use update dispatcher now. If our componet A() got run again, useEffect() would also be run again, it leads to updateEffect().
For when A() gets run again, please refer to my blog post How does React bailout work in reconciliation).
jsfunction 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);}
jsfunction 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);}
Things are a bit complex here,
- what is done in
updateWorkInProgressHook()? - what is
currentHook? - what happens if
areHookInputsEqual()passes? and what if not.
To begin answering question, we need to recall what we know about reconciliation, basically, React has a fiber tree (current) in which each fiber has a alternate copy, which means we have a alternate fiber tree.
Reconciliation means we doing those updates on the alternate, or we say workInProgress fiber tree, then just switch to this updated tree.
Letâs first visit 2 global variables, which is used in updateWorkInProgressHook(),
js// Hooks are stored as a linked list on the fiber's memoizedState field. The// current hook list is the list that belongs to the current fiber. The// work-in-progress hook list is a new list that will be added to the// work-in-progress fiber.let currentHook: Hook | null = null;let workInProgressHook: Hook | null = null;
js// Hooks are stored as a linked list on the fiber's memoizedState field. The// current hook list is the list that belongs to the current fiber. The// work-in-progress hook list is a new list that will be added to the// work-in-progress fiber.let currentHook: Hook | null = null;let workInProgressHook: Hook | null = null;
So we are keeping track of the hook being processed in current tree currentHook, and the one in the workInProgress tree, which is workInProgressHook.
Why we keep track of these two ? because we want to comparing them, so that we know if deps has changed.
Letâs see inside of updateWorkInProgressHook().
jslet nextCurrentHook: null | Hook;if (currentHook === null) {const current = currentlyRenderingFiber.alternate;if (current !== null) {nextCurrentHook = current.memoizedState;} else {nextCurrentHook = null;}} else {nextCurrentHook = currentHook.next;}
jslet nextCurrentHook: null | Hook;if (currentHook === null) {const current = currentlyRenderingFiber.alternate;if (current !== null) {nextCurrentHook = current.memoizedState;} else {nextCurrentHook = null;}} else {nextCurrentHook = currentHook.next;}
First it updates nextCurrentHook, which is the next hook of currentHook in current tree. Reasonable since we are going to create the hooks again, we need to find the previous existing hook.
Here we can see why the hooks must be stable, because it all depends on the order.
jslet nextWorkInProgressHook: null | Hook;if (workInProgressHook === null) {nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;} else {nextWorkInProgressHook = workInProgressHook.next;}
jslet nextWorkInProgressHook: null | Hook;if (workInProgressHook === null) {nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;} else {nextWorkInProgressHook = workInProgressHook.next;}
And do the same for nextWorkInProgressHook.
jsif (nextWorkInProgressHook !== null) {// There's already a work-in-progress. Reuse it.workInProgressHook = nextWorkInProgressHook;nextWorkInProgressHook = workInProgressHook.next;currentHook = nextCurrentHook;}
jsif (nextWorkInProgressHook !== null) {// There's already a work-in-progress. Reuse it.workInProgressHook = nextWorkInProgressHook;nextWorkInProgressHook = workInProgressHook.next;currentHook = nextCurrentHook;}
if we find nextWorkInProgressHook, then we can safely move one step forward, by updating currentHook and workInProgressHook.
jselse {if (nextCurrentHook === null) {throw new Error('Rendered more hooks than during the previous render.');}currentHook = nextCurrentHook;const newHook: Hook = {memoizedState: currentHook.memoizedState,baseState: currentHook.baseState,baseQueue: currentHook.baseQueue,queue: currentHook.queue,next: null,};if (workInProgressHook === null) {// This is the first hook in the list.currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;} else {// Append to the end of the list.workInProgressHook = workInProgressHook.next = newHook;}}
jselse {if (nextCurrentHook === null) {throw new Error('Rendered more hooks than during the previous render.');}currentHook = nextCurrentHook;const newHook: Hook = {memoizedState: currentHook.memoizedState,baseState: currentHook.baseState,baseQueue: currentHook.baseQueue,queue: currentHook.queue,next: null,};if (workInProgressHook === null) {// This is the first hook in the list.currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;} else {// Append to the end of the list.workInProgressHook = workInProgressHook.next = newHook;}}
If we donât find nextWorkInProgressHook, it means we need to create a new hook.
Why sometimes nextWorkInProgressHook is null and sometimes not ?
Good question, when our component A() gets rerun in renderWithHooks(), their memoizedState and updateQueue are actually reset
jsworkInProgress.memoizedState = null;workInProgress.updateQueue = null;workInProgress.lanes = NoLanes;
jsworkInProgress.memoizedState = null;workInProgress.updateQueue = null;workInProgress.lanes = NoLanes;
So I guess it always be null, correct me if Iâm wrong.
deps are compared
jsif (areHookInputsEqual(nextDeps, prevDeps)) {hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);return;}
jsif (areHookInputsEqual(nextDeps, prevDeps)) {hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);return;}
If deps are the equal, meaning, we donât need to run this effect hook, just pushEffect as if it is mounted, without hasEffect flag.
Why not just use the memoizedState from current hook? Good question, I am not sure. One thing I know is that tag on effect is not reset after effect is done, so we cannot reuse it easily without resetting the tag and cleaning up the next, maybe thatâs why using pushEffect() could save effort.
if deps changes, then effect hook needs to be run, which is the last piece of code
jshook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,destroy,nextDeps);
jshook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,destroy,nextDeps);
Now we have HookHasEffect flag, which means it is going to be run in flushPassiveEffects().
Cool, seems like our 2nd question has been answered, and also the 3rd one.
Letâs summarize
-
an
Effectkeep tracks our functions passed touseEffect, it has following propertiestag: it might containsHasEffectto mark if it needs to runcreate: the function we pass in,destroy: the cleanup function returned increate
-
Effectis linked in a list in order and attached to a fiber inupdateQueue -
when
useEffect()is run for the first time- a new hook is created , attached to
memoizedState - a new
Effectis created with the tagHasEffect, attached to the fiber inupdateQueue
- a new hook is created , attached to
-
when an effect is run (mounted),
createis invoked anddestroyis set to the return value, which means- if an effect has
HasEffectflag and alsodestroy, then destroy should be called
- if an effect has
-
in
flushPassiveEffects, it traverse through the fiber tree in 2 passes- first search for effects that has
HasEffectanddestroy, clean them up- also it handles cleanup caused by fiber deletion.
- then search for effects that has
HasEffect, and re-run them.
- first search for effects that has
-
when component is rerendered, the workInProgress fiber has empty
memoizedStateandemptyQueue.useEffect()is run again,depsare compared. If changes are found, a new effect is created withhasEffect
Iâm not sure why the HasEffect is not revoked in flushPassiveEffects() , maybe because we are only mount/unmount in the commit phase, there is no need to revoke since the reconciliation is already done. The next time we go to render phase, the effects are reconstructed.
Hope it helps you understand the lifecycle of an effect hook. There will be more posts about React, stay tuned.
Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!
