In-Depth Article
How useEffect Works in React: Effect Objects, Fiber Flags, and flushPassiveEffects
Learn how useEffect really works in React: render-phase effect creation, dependency checks with Object.is, Fiber passive flags, circular effect lists, and how flushPassiveEffects runs cleanup and setup later.
useEffect is one of the most widely used Hooks in React, but its real behavior is easier to understand if you stop thinking of it as "run this after render" and look at the actual pipeline.
The more precise version is this:
During render, React creates effect descriptors and marks passive work. During commit, React schedules passive effects for a later flush, where cleanup runs first and setup runs afterward.
That explains most of the behavior developers notice in real apps:
- why
useEffectdoes not run during render - why dependencies decide whether an effect is marked as active work
- why cleanup runs before the next effect setup
- why
useEffectusually does not block paint
If you want nearby context first, this article pairs well with useEffect vs useLayoutEffect: the real difference in React’s commit phase, React stale closure: why your callback sees old state and how to fix it, and how React Scheduler works: min-heaps, MessageChannel, yielding, and priority timeouts.
1. Start with the most important boundary: useEffect does not run in the render phase
When your component function executes, React is still in the render phase.
At that moment, useEffect does not run your side-effect callback.
Instead, React does bookkeeping:
- it creates or updates Hook state for that effect call site
- it stores dependency information
- it marks whether the effect should actually run later
- it links the effect into the current Fiber's effect queue
That is why React can keep rendering pure.
If React ran network requests, subscriptions, DOM listeners, or cleanup logic while still rendering the tree, render would no longer be a safe "calculate the next UI" phase. So React splits the job in two:
- render decides what passive work exists
- commit and passive flushing decide when that work actually runs
That separation is the key to the whole Hook.
2. The data structure: one Hook node, one Effect record, one Fiber-level ring list
At the component level, every Hook call already has its own Hook node in the Fiber Hook list.
For useEffect, the Hook node's memoizedState points to an Effect record. In current React source, a simplified shape looks like this:
const effect = {
tag,
create,
deps,
inst: {
destroy: undefined,
},
next: null, // circular list link
};
There are two levels of storage that matter.
Hook-level storage
The individual Hook node stores the effect for that one call site:
hook.memoizedState = effect;
That is how React can come back to "the useEffect in this position" on the next render and compare dependencies.
Fiber-level storage
At the same time, React also pushes the effect into the current Fiber's function-component update queue.
That queue has a lastEffect pointer, and all effects are linked together as a singly linked circular list:
Fiber.updateQueue.lastEffect
-> Effect A
-> Effect B
-> Effect C
-> back to Effect A
That ring list is what commit-time traversal uses later.
So the clean storage model is:
- the Hook node remembers the effect for this Hook position
- the Fiber queue collects all effects for this component so commit can walk them efficiently
One subtle but important source-level detail
Many simplified explanations show destroy directly on the effect object itself.
Current React source stores cleanup on effect.inst.destroy instead.
That detail matters because React can build a fresh effect record for the new render while still reusing the cleanup holder object across updates.
3. What happens on mount: React marks the effect as work that must run
On the first render, useEffect(create, deps) goes through the mount path.
At a high level, React does something like this:
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags,
createEffectInstance(),
create,
nextDeps
);
}
For useEffect, the hook-level tag is the passive tag, and React also marks the Fiber with a passive-related Fiber flag so commit knows that this subtree has passive work waiting.
The most important point is the HookHasEffect bit.
That bit means:
This effect is not just present in the list. It needs to be executed in the passive phase.
So on mount, React does not merely store the effect. It stores it as active work.
4. What happens on updates: React compares dependencies and may drop the HookHasEffect bit
On a later render, useEffect goes through the update path.
This is where dependency comparison matters.
Conceptually, React does this:
- read the new dependency array
- read the previous effect from the current Hook
- compare
nextDepswithprevDeps - decide whether this effect should be marked as runnable
The current source logic is roughly:
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const effect = hook.memoizedState;
const inst = effect.inst;
if (currentHook !== null && nextDeps !== null) {
const prevEffect = currentHook.memoizedState;
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushSimpleEffect(
hookFlags,
inst,
create,
nextDeps
);
return;
}
}
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags,
inst,
create,
nextDeps
);
}
There are three useful takeaways here.
React still creates a new effect record on update
Even when dependencies are unchanged, React still pushes a new effect record into the work-in-progress queue for the new render.
What changes is not "was an effect object created?" What changes is:
Did React set
HookHasEffect, and did it mark the Fiber as having passive work to flush?
Dependency comparison is shallow and element by element
React uses the same areHookInputsEqual(...) style logic used by other dependency-based Hooks.
Each dependency item is compared with Object.is.
That means:
- primitive equality usually behaves the way you expect
- objects, arrays, and functions are compared by reference
- React is not doing a deep comparison for you
Unchanged dependencies mean "present but inactive"
If dependencies did not change, React pushes the effect with the passive tag, but without HookHasEffect.
That means commit-time traversal can still see the effect entry, but it will skip executing it for this render.
Here is the quick mental model:
| Case | What React does |
|---|---|
useEffect(fn) | effect is treated as needing work every render |
useEffect(fn, []) | effect is active on mount, then inactive on later renders unless remounted |
useEffect(fn, [a, b]) | effect becomes active again only when a or b changes by Object.is |
5. Render builds and marks the work, but commit decides when passive effects flush
Once render finishes, React enters commit.
This is where useEffect diverges from useLayoutEffect.
If the finished tree contains passive work, React marks that the root has a pending passive phase and schedules passive flushing later. In the standard browser path of current React source, this goes through a normal-priority Scheduler callback that eventually calls flushPassiveEffects().
Conceptually:
if (rootDidHavePassiveEffects) {
pendingEffectsStatus = PENDING_PASSIVE_PHASE;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
So the most accurate timing statement is this:
useEffectis deferred out of the main synchronous commit path into a later passive flush.
That is why useEffect is the default choice for ordinary side effects: it usually does not block the DOM commit itself.
"After paint" is the right default mental model, but not an absolute law
In practice, developers usually describe useEffect as running after paint.
That is a very good default mental model, and it matches why visual work in useEffect can flicker.
But the safest source-aware wording is a little more precise:
- passive effects are deferred until a later flush
- that later flush usually happens after the browser has had a chance to paint
- React docs also note that interaction-caused effects may sometimes be observed before paint depending on scheduling needs
So do not memorize "useEffect is always post-paint" as a hard physics rule.
Memorize this instead:
useEffectis passive, deferred work, and unlikeuseLayoutEffect, it is not meant to block the screen update.
6. What flushPassiveEffects actually does: unmount pass first, mount pass second
When the passive flush runs, React does not just "run all the effect callbacks."
The source-level flow is more structured:
commitPassiveUnmountEffects(root.current);
commitPassiveMountEffects(root, root.current, lanes, transitions, ...);
That is a very important detail.
At the root level, passive cleanup runs before passive setup.
So if you want the clean mental model, it is:
- run passive cleanups that need to happen
- then run passive setups for the new render
That is more accurate than imagining one simple alternating sequence for the whole tree.
7. Inside each pass, React walks the Fiber effect list and filters by flags
When React reaches a Fiber with passive work, it eventually calls hook-effect helpers that traverse the component's circular effect list.
The core shape looks like this:
function commitHookEffectListMount(flags, finishedWork) {
const lastEffect = finishedWork.updateQueue?.lastEffect;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
const destroy = effect.create();
effect.inst.destroy = destroy;
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
And the unmount side looks like this:
function commitHookEffectListUnmount(flags, finishedWork) {
const lastEffect = finishedWork.updateQueue?.lastEffect;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
const destroy = effect.inst.destroy;
if (destroy !== undefined) {
effect.inst.destroy = undefined;
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
That shows the two key jobs very clearly:
- mount pass calls
create()and stores the returned cleanup - unmount pass reads the stored cleanup and calls it
So the cleanup function is not magical hidden state. React stores it explicitly so the next passive unmount phase can use it.
8. Why cleanup runs before the next setup
This is one of the most important practical behaviors to understand.
Suppose your effect depends on roomId:
useEffect(() => {
const connection = connect(roomId);
return () => connection.disconnect();
}, [roomId]);
When roomId changes, React does not just run the new setup immediately and leave the old subscription alive.
The effect pipeline is closer to this:
- render sees
roomIdchanged - React marks the effect with
HookHasEffect - commit schedules passive work
- passive unmount phase disconnects the old subscription
- passive mount phase connects the new subscription
That is the reason the cleanup API exists in the first place.
It keeps side effects synchronized with the dependency snapshot they belong to.
9. What this explains in real code
Once you see the actual pipeline, several common React behaviors stop feeling mysterious.
Why useEffect does not belong in render
Render only builds and marks effect work. The side effect itself is executed later.
Why dependency arrays matter so much
They decide whether React should add HookHasEffect for this render or treat the effect as present but inactive.
Why stale closures happen
The effect callback belongs to the render where it was created. If the dependency array does not reflect the data the effect reads, React may keep reusing an older closure. If you want that issue in depth, see React stale closure: why your callback sees old state and how to fix it.
Why visual layout fixes often need useLayoutEffect instead
Because useEffect is deferred passive work, it is a poor place for logic that must run before the browser paints. That is exactly the problem space covered by useEffect vs useLayoutEffect: the real difference in React’s commit phase.
10. A clean mental model to keep
If you want one compact explanation to remember, use this:
useEffectdoes not run side effects during render- Render creates an Effect record and links it into the Fiber's circular effect list
- React compares dependencies with
Object.is - If dependencies changed, React adds
HookHasEffectand marks passive work on the Fiber - Commit schedules a later passive flush
- During that flush, React runs passive unmounts first, then passive mounts
- The cleanup returned by
create()is stored and reused on the next unmount pass
So the deepest correct explanation is not "useEffect runs after render." It is:
useEffectis a passive effect system built on Hook state, Fiber-linked effect queues, dependency-based marking, and a later flush that runs cleanup before setup.
Once that clicks, useEffect stops feeling like a vague lifecycle callback. It becomes a concrete two-stage pipeline: render creates and marks passive work, then commit flushes that passive work under clear rules.
Reviewed by
DevDepth Editor
Editor and frontend engineering writer
DevDepth publishes practical guides on React, Next.js, TypeScript, frontend architecture, browser APIs, and performance optimization.
Each article should be reviewed for technical accuracy, code clarity, metadata quality, and internal-link fit before it goes live.
Last editorial review: 2026-03-17