How does useEffect() work internally in React?
useEffect()
might be the most used hook in React except useState()
.
It is quite powerful but sometimes confusing, let’s figure out how it works internally.
js
useEffect(() => {// ...}, [deps])
js
useEffect(() => {// ...}, [deps])
1. useEffect()
in initial mount.
useEffect()
uses mountEffect()
on initial mount.
function mountEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,): void {return mountEffectImpl(PassiveEffect | PassiveStaticEffect,-----------------------------------This flag is important, to tell the difference from Layout Effects.
What is PassiveStaticEffect? This might be worth another episod.
HookPassive,create,deps,);}function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = mountWorkInProgressHook();create a new hook
const nextDeps = deps === undefined ? null : deps;currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(pushEffect() creates the Effect object, and then set it on the hook
HookHasEffect | hookFlags,-------------This flag is important, it means we need to run this effect on initial mount
create,undefined,nextDeps,);}
function mountEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,): void {return mountEffectImpl(PassiveEffect | PassiveStaticEffect,-----------------------------------This flag is important, to tell the difference from Layout Effects.
What is PassiveStaticEffect? This might be worth another episod.
HookPassive,create,deps,);}function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = mountWorkInProgressHook();create a new hook
const nextDeps = deps === undefined ? null : deps;currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(pushEffect() creates the Effect object, and then set it on the hook
HookHasEffect | hookFlags,-------------This flag is important, it means we need to run this effect on initial mount
create,undefined,nextDeps,);}
function pushEffect(tag, create, destroy, deps) {const effect: Effect = {tag,tag is important, it is used to mark if this effect needs to be run or not
create,The callback we pass in
destroy,The cleanup function we returned in the callback
deps,The deps array we pass in
// Circularnext: (null: any),There could be multiple effects in one component, chain them up
};let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);if (componentUpdateQueue === null) {componentUpdateQueue = createFunctionComponentUpdateQueue();currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);------------------------------------------------------------------Effects are stored in
updateQueue
on the fiberNotice this is different from the
memoizedState
from the hookscomponentUpdateQueue.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;}
function pushEffect(tag, create, destroy, deps) {const effect: Effect = {tag,tag is important, it is used to mark if this effect needs to be run or not
create,The callback we pass in
destroy,The cleanup function we returned in the callback
deps,The deps array we pass in
// Circularnext: (null: any),There could be multiple effects in one component, chain them up
};let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);if (componentUpdateQueue === null) {componentUpdateQueue = createFunctionComponentUpdateQueue();currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);------------------------------------------------------------------Effects are stored in
updateQueue
on the fiberNotice this is different from the
memoizedState
from the hookscomponentUpdateQueue.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;}
We can see that for initial mount, useEffect()
creates the Effect object with necessary flags.
They’ll be processed in some other timing.
2. useEffect()
in re-render
function updateEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,): void {return updateEffectImpl(PassiveEffect, HookPassive, create, deps);}function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = updateWorkInProgressHook();Get current hook
const nextDeps = deps === undefined ? null : deps;let destroy = undefined;if (currentHook !== null) {const prevEffect = currentHook.memoizedState;Remember the
memoizedState
of effect hook is the Effect objectdestroy = prevEffect.destroy;if (nextDeps !== null) {const prevDeps = prevEffect.deps;if (areHookInputsEqual(nextDeps, prevDeps)) {hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);return;}if deps don't change, then do nothing but recreate the Effect object
we need to recreate here mabe because simply we need to recreate the updateQueue
and we need to get the updated
create()
.notice that here we are using the previous
destroy()
}}currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,-------------If deps change, then
HookHasEffect
is to mark this effect needs to be runcreate,destroy,nextDeps,);}
function updateEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,): void {return updateEffectImpl(PassiveEffect, HookPassive, create, deps);}function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {const hook = updateWorkInProgressHook();Get current hook
const nextDeps = deps === undefined ? null : deps;let destroy = undefined;if (currentHook !== null) {const prevEffect = currentHook.memoizedState;Remember the
memoizedState
of effect hook is the Effect objectdestroy = prevEffect.destroy;if (nextDeps !== null) {const prevDeps = prevEffect.deps;if (areHookInputsEqual(nextDeps, prevDeps)) {hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);return;}if deps don't change, then do nothing but recreate the Effect object
we need to recreate here mabe because simply we need to recreate the updateQueue
and we need to get the updated
create()
.notice that here we are using the previous
destroy()
}}currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,-------------If deps change, then
HookHasEffect
is to mark this effect needs to be runcreate,destroy,nextDeps,);}
We can see the how the confusing deps array works. On re-render, we’ll recreate the Effect objects no matter what, except that if deps change, the created Effect are marked to be run, with the previous cleanup function.
3. When and how does effects get run and cleaned up ?
From above we now know that useEffect()
merely creates extra data structure on fiber nodes.
Now we need to figure out how those Effect objects are processed.
3.1 flushing of passive effects are triggered in commitRoot()
After getting the diffing result from comparing two fiber tree(reconciliation), we’ll reflect the changes to host DOM in committing phase. We can easily find the code that initiated the flushing of passive 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 setTimeoutpendingPassiveTransitions = 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
For more refer to how react scheduler works
}}...}
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 setTimeoutpendingPassiveTransitions = 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
For more refer to how react scheduler works
}}...}
3.2 flushPassiveEffects()
function flushPassiveEffectsImpl() {if (rootWithPendingPassiveEffects === null) {return false;}// Cache and clear the transitions flagconst transitions = pendingPassiveTransitions;pendingPassiveTransitions = null;const root = rootWithPendingPassiveEffects;const lanes = pendingPassiveEffectsLanes;rootWithPendingPassiveEffects = null;// TODO: This is sometimes out of sync with rootWithPendingPassiveEffects.// Figure out why and fix it. It's not causing any known issues (probably// because it's only used for profiling), but it's a refactor hazard.pendingPassiveEffectsLanes = NoLanes;const prevExecutionContext = executionContext;executionContext |= CommitContext;commitPassiveUnmountEffects(root.current);commitPassiveMountEffects(root, root.current, lanes, transitions);Here we see clearly the effect cleanups are run first before callbacks are run
...}
function flushPassiveEffectsImpl() {if (rootWithPendingPassiveEffects === null) {return false;}// Cache and clear the transitions flagconst transitions = pendingPassiveTransitions;pendingPassiveTransitions = null;const root = rootWithPendingPassiveEffects;const lanes = pendingPassiveEffectsLanes;rootWithPendingPassiveEffects = null;// TODO: This is sometimes out of sync with rootWithPendingPassiveEffects.// Figure out why and fix it. It's not causing any known issues (probably// because it's only used for profiling), but it's a refactor hazard.pendingPassiveEffectsLanes = NoLanes;const prevExecutionContext = executionContext;executionContext |= CommitContext;commitPassiveUnmountEffects(root.current);commitPassiveMountEffects(root, root.current, lanes, transitions);Here we see clearly the effect cleanups are run first before callbacks are run
...}
3.3 commitPassiveUnmountEffects()
export function commitPassiveUnmountEffects(finishedWork: Fiber): void {setCurrentDebugFiberInDEV(finishedWork);commitPassiveUnmountOnFiber(finishedWork);---------------------------resetCurrentDebugFiberInDEV();}function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {recursivelyTraversePassiveUnmountEffects(finishedWork);We can see that children's effects are cleaned up first
if (finishedWork.flags & Passive) {commitHookPassiveUnmountEffects(-------------------------------finishedWork,finishedWork.return,HookPassive | HookHasEffect,-------------This flag makes sure callbacks are not run if deps don't change
);}break;}...}}function commitHookPassiveUnmountEffects(finishedWork: Fiber,nearestMountedAncestor: null | Fiber,hookFlags: HookFlags,) {if (shouldProfile(finishedWork)) {startPassiveEffectTimer();commitHookEffectListUnmount(---------------------------hookFlags,finishedWork,nearestMountedAncestor,);recordPassiveEffectDuration(finishedWork);} else {commitHookEffectListUnmount(---------------------------hookFlags,finishedWork,nearestMountedAncestor,);}}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 inst = effect.inst;const destroy = inst.destroy;if (destroy !== undefined) {inst.destroy = undefined;safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);}}effect = effect.next;} while (effect !== firstEffect);Here simply loop through all the Effects from updateQueue
and filter out the necessary ones by flags
}}function safelyCallDestroy(current: Fiber,nearestMountedAncestor: Fiber | null,destroy: () => void,) {try {destroy();} catch (error) {captureCommitPhaseError(current, nearestMountedAncestor, error);}}
export function commitPassiveUnmountEffects(finishedWork: Fiber): void {setCurrentDebugFiberInDEV(finishedWork);commitPassiveUnmountOnFiber(finishedWork);---------------------------resetCurrentDebugFiberInDEV();}function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {recursivelyTraversePassiveUnmountEffects(finishedWork);We can see that children's effects are cleaned up first
if (finishedWork.flags & Passive) {commitHookPassiveUnmountEffects(-------------------------------finishedWork,finishedWork.return,HookPassive | HookHasEffect,-------------This flag makes sure callbacks are not run if deps don't change
);}break;}...}}function commitHookPassiveUnmountEffects(finishedWork: Fiber,nearestMountedAncestor: null | Fiber,hookFlags: HookFlags,) {if (shouldProfile(finishedWork)) {startPassiveEffectTimer();commitHookEffectListUnmount(---------------------------hookFlags,finishedWork,nearestMountedAncestor,);recordPassiveEffectDuration(finishedWork);} else {commitHookEffectListUnmount(---------------------------hookFlags,finishedWork,nearestMountedAncestor,);}}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 inst = effect.inst;const destroy = inst.destroy;if (destroy !== undefined) {inst.destroy = undefined;safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);}}effect = effect.next;} while (effect !== firstEffect);Here simply loop through all the Effects from updateQueue
and filter out the necessary ones by flags
}}function safelyCallDestroy(current: Fiber,nearestMountedAncestor: Fiber | null,destroy: () => void,) {try {destroy();} catch (error) {captureCommitPhaseError(current, nearestMountedAncestor, error);}}
3.4 commitPassiveMountEffects()
commitPassiveMountEffects()
works the same way
export function commitPassiveMountEffects(root: FiberRoot,finishedWork: Fiber,committedLanes: Lanes,committedTransitions: Array<Transition> | null,): void {setCurrentDebugFiberInDEV(finishedWork);commitPassiveMountOnFiber(-------------------------root,finishedWork,committedLanes,committedTransitions,);resetCurrentDebugFiberInDEV();}function commitPassiveMountOnFiber(finishedRoot: FiberRoot,finishedWork: Fiber,committedLanes: Lanes,committedTransitions: Array<Transition> | null,): void {// When updating this function, also update reconnectPassiveEffects, which does// most of the same things when an offscreen tree goes from hidden -> visible,// or when toggling effects inside a hidden tree.const flags = finishedWork.flags;switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {recursivelyTraversePassiveMountEffects(We can see that children's effects are run first
finishedRoot,finishedWork,committedLanes,committedTransitions,);if (flags & Passive) {commitHookPassiveMountEffects(-----------------------------finishedWork,HookPassive | HookHasEffect,-------------This flag makes sure callbacks are not run if deps don't change
);}break;}...}}function commitHookPassiveMountEffects(finishedWork: Fiber,hookFlags: HookFlags,) {if (shouldProfile(finishedWork)) {startPassiveEffectTimer();try {commitHookEffectListMount(hookFlags, finishedWork);-------------------------} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}recordPassiveEffectDuration(finishedWork);} else {try {commitHookEffectListMount(hookFlags, finishedWork);-------------------------} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}}}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;const inst = effect.inst;const destroy = create();------------------------callback is run here!
inst.destroy = destroy;----------------------}effect = effect.next;} while (effect !== firstEffect);Again, filter out the necessary Effects and run
}}
export function commitPassiveMountEffects(root: FiberRoot,finishedWork: Fiber,committedLanes: Lanes,committedTransitions: Array<Transition> | null,): void {setCurrentDebugFiberInDEV(finishedWork);commitPassiveMountOnFiber(-------------------------root,finishedWork,committedLanes,committedTransitions,);resetCurrentDebugFiberInDEV();}function commitPassiveMountOnFiber(finishedRoot: FiberRoot,finishedWork: Fiber,committedLanes: Lanes,committedTransitions: Array<Transition> | null,): void {// When updating this function, also update reconnectPassiveEffects, which does// most of the same things when an offscreen tree goes from hidden -> visible,// or when toggling effects inside a hidden tree.const flags = finishedWork.flags;switch (finishedWork.tag) {case FunctionComponent:case ForwardRef:case SimpleMemoComponent: {recursivelyTraversePassiveMountEffects(We can see that children's effects are run first
finishedRoot,finishedWork,committedLanes,committedTransitions,);if (flags & Passive) {commitHookPassiveMountEffects(-----------------------------finishedWork,HookPassive | HookHasEffect,-------------This flag makes sure callbacks are not run if deps don't change
);}break;}...}}function commitHookPassiveMountEffects(finishedWork: Fiber,hookFlags: HookFlags,) {if (shouldProfile(finishedWork)) {startPassiveEffectTimer();try {commitHookEffectListMount(hookFlags, finishedWork);-------------------------} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}recordPassiveEffectDuration(finishedWork);} else {try {commitHookEffectListMount(hookFlags, finishedWork);-------------------------} catch (error) {captureCommitPhaseError(finishedWork, finishedWork.return, error);}}}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;const inst = effect.inst;const destroy = create();------------------------callback is run here!
inst.destroy = destroy;----------------------}effect = effect.next;} while (effect !== firstEffect);Again, filter out the necessary Effects and run
}}
4. Summary
After looking at the source code, we found that the internals of useEffect()
is quite straightforward.
useEffect()
creates Effect objects that are stored on the fiber.- Effect has
tag
to indicate if it needs to be run or not - Effect has
create()
which is the first argument we pass in - Effect has
destroy()
which is the cleanup fromcreate()
, it is only set whencreate()
is run
- Effect has
useEffect()
creates new Effect objects every time, but it sets differenttag
if deps array change- In commiting updates to host DOM, a job in next tick is scheduled to re-run all the Effects based on the
tag
.- Effects in child components are processed first
- cleanups are run first
5. Quiz Challenge
Now with what we’ve learned today, you are able to solve this React quiz from BFE.dev
Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!