The best way I have found to really have an accurate mental model of the programming abstractions I use whether compilers, promises or frameworks like react, it is to crack open the blackbox and understand the essential implementation details.
While there are a number of excellent posts on how hooks work under the hood, the inner workings of useEffect and how it relates to the lifecycle of a component continue to be a source of puzzlement for many.
As I’ll attempt to show when you peak behind the curtain the useEffect
hook’s implementation is quite straightforward and fits elegantly into React’s reconciliation algorithm.
By the end I hope we’ll be able to confidently answer questions such as:
- Why do we have to call
useEffect
hooks in the same order? - How are hooks represented by a fiber?
- When and how exactly are values in the dependency array compared?
- When and how are effects cleaned up?
- Why can’t we take advantage of React fibers in my useEffect callbacks?
First, let’s briefly recap how React fibers and the reconciliation algorithm work; during reconciliation React builds up a work-in-progress fiber tree and computes a set of changes by walking the component tree and recursively calling render
. Each React element is thus turned into a fiber node of corresponding type that keeps record of the work to be done. Think of a fiber as representing a unit of work that can be independently scheduled, paused and aborted.
When an update is called, React will add the update to a component update queue, for instance, when setState is called on render, React calls the updater function which was passed into setState. After the updater is finished the fiber gets a tag that a change needs to be made in the DOM.
The list of changes are then propagated up to the parent fiber and merged into its list of changes. This list of changes is also called effect list. When React reaches the root node the work in progress tree is marked as a pending commit.
Those changes, however, are not immediately committed to a rendering target such as the DOM. That happens in the commit phase, this phase is atomic and cannot be interrupted, otherwise there might be UI inconsistencies.
During the commit phase React iterates over the effect list and makes its changes to the rendering target (e.g. DOM).
Let’s look at some code:useEffect
is defined in ReactHooks.js
and its type signature clues us in to how it works; it accepts as first argument a function creating the effect, which optionally returns a function (cleaning up the effect) and as second argument an optional array of inputs (the dependency array) of variable type.
We see that the functions first resolves a dispatcher and then delegates to it.
//react/blob/master/packages/react/src/ReactHooks.js#L104
export function useEffect(
create: () => (() => void) | void,
inputs: Array<mixed> | void | null,
) {
const dispatcher = resolveDispatcher();
return dispatcher.useEffect(create, inputs);
}
The hook dispatchers are resolved depending on the current context, if it's the initial render and the component just mounted HooksDispatcherOnMount
and otherwise HooksDispatcherOnUpdate
is returned, correspondingly the dispatcher returns either mountEffect
or updateEffect
.
//react/blob/master/packages/react-reconciler/src/ReactFiberHooks.old.js#L570
const HooksDispatcherOnMount: Dispatcher = {
...
useEffect: mountEffect,
...
};
const HooksDispatcherOnUpdate: Dispatcher = {
...
useEffect: updateEffect,
...
}
Without looking at the implementation, from our experience working with useEffect
we know that these cases differ in at least one respect; the create function of useEffect
is always invoked on mount, regardless of its second argument.
Let us first look at the more common update case; updateEffect
delegates to updateEffectImpl
to pass in the current fiber and hook effect tags. I don’t want to go too much into effect tags here, suffice it to mention that each fiber’s effects are encoded in an effectTag
, they define the work that needs to be done for instances after updates have been processed, similarly there are hook effect tags carrying information about the hook effect's context, e.g. whether the component is unmounting or whether the effect should be invoked at all (the NoHookEffect
tag).updateEffectImpl
first calls updateWorkInProgressHook
to get a new hook instance, which is basically just a clone of the current hook or if we are in a work-in-progress tree the current work-in-progress hook:
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
baseState: currentHook.baseState,
queue: currentHook.queue,
baseUpdate: currentHook.baseUpdate,
next: null,
};
When a hook is called in our component it builds up a queue where hooks are represented as linked list in their call order with each hook’s next
field pointing to the next hook. Since these are copied over from each render, we see why we cannot call hooks conditionally or change their call order from render to render.
The baseState
and baseUpdate
fields are relevant to useState
and useDispatch
hooks, useEffect
most importantly uses memoizedState
to hold a reference to the previous effect. Let’s look at why.
//react/packages/react-reconciler/src/ReactFiberHooks.old.js#L1218
function updateEffectImpl(fiberEffectTag, hookEffectTag, 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)) {
pushEffect(NoHookEffect, create, destroy, nextDeps);
return;
}
}
}
sideEffectTag |= fiberEffectTag;
hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}
The most interesting thing happening here is that if there is a currentHook
we fetch the previous effect from the hook’s memoizedState
field to get the previous dependencies and compare them to the next dependencies. If they are equal, we push an effect onto the queue with the NoHookEffect
tag and return, which means that the effect will still be run during commit, but it won’t be executed (its create
function won't be invoked). Finally, if the dependencies are not equal, we push the effect onto the queue with an effect tag that ensures the effect will fire.
As a side note areHookInputsEqual
delegates to Object.is
instead of a plain object reference comparison to catch javascript quirks such as NaN === NaN // false
.
We skip over the source ofmountEffectImpl
here, since it only differs from updateEffectImpl
in that it does not check the dependency array and simply pushes the hook on the effect queue to be executed.
That is basically all that happens during reconciliation; values from previous useEffect hooks are cloned, the new dependencies compared to previous ones which were saved on the memoizedState
field to determine whether the effect should fire or not and that information is pushed on the effect queue.
The next time we see our effect is after React has finished reconciliation, every render has been called and the list of updates to be committed to the rendering target aggregated. We are in the commit phase now and commitWork
calls commitHookEffectList
:
function commitWork(current: Fiber | null, finishedWork: Fiber): void {
...
commitHookEffectList(UnmountMutation, MountMutation, finishedWork);
...
}
commitHookEffectList
in turn iterates over the effect list, checks the tag to determine in which phase the effect has been added to the list and fires create
or destroy
respectively.
We see that in the case of an unmountTag
the destroy
clean up function is called. In case, we are in an update phase, create
is called firing the effect and the destroy
function returned from `createz is simply saved on the effect for future reference in the unmount phase. If the effect has been tagged with NoHookEffect
it is simply skipped.
// react/ReactFiberCommitWork.old.js at master · facebook/react
function commitHookEffectList(
unmountTag: number,
mountTag: number,
finishedWork: Fiber,
) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & unmountTag) !== NoHookEffect) {
// Unmount
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
if ((effect.tag & mountTag) !== NoHookEffect) {
// Mount
const create = effect.create;
effect.destroy = create();
if (__DEV__) {...}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
Now we also see why the code we run in useEffect
cannot take advantage of fibers which are able to pause in order to let other higher priority work finish before rendering is resumed. This is because the effect is executed inside of commitWork
which makes atomic changes to the rendering target to avoid UI inconsistencies. This is important to bear in mind lest one is tempted to perform computationally intensive, synchronous work inside a useEffect
hook.
I hope this basic understanding of how useEffect
works under the hood helps you become more confident working with useEffect
and avoid common pitfalls. It may also have encouraged you to pull away the curtain once in a while and take a look at the React source to deepen your understanding. The most difficult to understand parts of the code are often related to performance and other house-keeping, but you shouldn't let that shroud your understanding of the central pieces that are concerned with React’s core functionality.
Happy source reading!