5.3 Effects: when useEffect actually fires
You write useEffect(() => { /* do something */ }) and expect "this runs after my component renders." But when exactly? Not during render. Not even the instant the screen updates. There is one precise moment — after the browser has painted the new pixels — and a sibling hook, useLayoutEffect, that fires earlier, before paint. Knowing exactly when each one runs is what separates "my effect re-ran when I didn't expect" and "my UI flickers" from code you can reason about.
Render collects effects — it never runs them
Here is the first surprise. When your component function runs during the render phase, a useEffect call does not call your function. It just records it. The function you passed is stored away as data, to be called later.
So render does two small things per effect: it builds an effect object that holds your function, and it marks the fiber with a flag so the commit phase knows "there is effect work here."
The real code
function mountEffect(create, deps) {
return mountEffectImpl(
PassiveEffect | PassiveStaticEffect, // flag for useEffect
HookPassive, // scheduled post-paint
create,
deps,
);
}
function mountLayoutEffect(create, deps) {
return mountEffectImpl(
UpdateEffect, // flag for useLayoutEffect
HookLayout, // runs during commit
create,
deps,
);
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// Mark the fiber so the commit phase knows there is work here
currentlyRenderingFiber.flags |= fiberFlags; // PassiveEffect or UpdateEffect
// Build the effect object and append it to the circular list
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create, // <-- the effect function, NOT called yet!
undefined,
nextDeps,
);
}
Read the comment on the create argument: NOT called yet. That one line is the whole idea. The two hooks differ only in which flags they pass — useEffect is "passive," useLayoutEffect is "layout" — and those flags decide when the stored function gets called.
| Hook | Fiber flag | Hook flag | When executed |
|---|---|---|---|
useEffect | PassiveEffect | HookPassive | Scheduled post-paint (asynchronous) |
useLayoutEffect | UpdateEffect | HookLayout | Layout subphase of commit (synchronous) |
Two kinds of effect, two moments
A React update moves through three stages. Effects are collected in the first, and run in the last two — at two different times.
┌────────────────────────────────────────────────────────────┐
│ 1. RENDER PHASE (Interruptible, in memory) │
│ - Call component functions │
│ - Collect effect objects (don't execute!) │
│ - Build fiber tree │
│ Duration: Variable (can be interrupted) │
└────────────────┬───────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 2. COMMIT PHASE (Synchronous, blocks main thread) │
│ Phase 2a: Before Mutation │
│ - Read current DOM state │
│ Phase 2b: Mutation │
│ - Update DOM (all changes applied) │
│ Phase 2c: Layout │
│ ★ RUN useLayoutEffect (SYNCHRONOUSLY) │
│ - Can read new DOM layout │
│ - BLOCKS browser paint │
│ Duration: Fast but synchronous (blocks everything) │
└────────────────┬───────────────────────────────────────────┘
│
▼
BROWSER PAINT
User sees new UI
│
▼
┌────────────────────────────────────────────────────────────┐
│ 3. POST-PAINT (Asynchronous, scheduled) │
│ ★ RUN useEffect (ASYNCHRONOUSLY) │
│ - Scheduled via scheduler │
│ - Does NOT block paint │
│ - User already sees updated UI │
│ Duration: Variable (deferred, non-blocking) │
└────────────────────────────────────────────────────────────┘
The dividing line is browser paint. A useLayoutEffect runs inside the commit, before paint — so it can measure or fix the DOM and the user never sees an in-between frame. A useEffect runs after paint, off to the side, so it can never block the user from seeing the new screen.
Same two hooks, drawn on a timeline:
useLayoutEffect Timeline:
┌─────────────────┬──────────────┬──────────┬─────────────────┐
│ Render Phase │ Commit Phase │ BLOCKED │ Paint │
│ (Build tree) │ (Update DOM) │ HERE! !! │ (User sees UI) │
└─────────────────┴──────────────┴──────────┴─────────────────┘
↑
useLayoutEffect runs here
Blocks paint!
useEffect Timeline:
┌─────────────────┬──────────────┬──────────┬─────────────────┐
│ Render Phase │ Commit Phase │ Paint │ Effect Runs │
│ (Build tree) │ (Update DOM) │ (UI!) │ (After paint) │
└─────────────────┴──────────────┴──────────┴─────────────────┘
↑
useEffect runs here
Does NOT block paint!
Passive effects are scheduled at commit time, not run
Now the part that names this lesson. Inside the commit, layout effects fire right away. Passive effects (useEffect) do not — React only schedules a callback to run them later, then lets the commit finish so the browser can paint.
Watch the two halves in commitRootImpl: layout effects called directly, passive effects handed to scheduleCallback.
The real code
// ReactFiberWorkLoop.js — inside commitRootImpl
function commitRootImpl(root, recoverableErrors, transitions) {
// ... DOM mutations (Phase 2b) ...
root.current = finishedWork; // swap current/workInProgress trees
// Phase 2c: layout effects run SYNCHRONOUSLY (blocks paint)
commitLayoutEffects(finishedWork, root, lanes);
// Schedule passive effects AFTER commit completes (async, post-paint)
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects(); // useEffect runs inside this callback
return null;
});
}
}
// commit phase ends — browser can now paint
}
function flushPassiveEffects() {
if (rootWithPendingPassiveEffects !== null) {
const root = rootWithPendingPassiveEffects;
commitPassiveUnmountEffects(root.current); // run all cleanups first
commitPassiveMountEffects(root, root.current); // then run all effects
return true;
}
return false;
}
Two details worth pausing on. First, the if (!rootDoesHavePassiveEffects) guard means React schedules one flush per commit, no matter how many useEffect hooks the tree has. Second, look at the order inside flushPassiveEffects: cleanups first, then the new effects. Every cleanup from the previous render runs before any fresh effect runs.
Put it all on a clock and the firing order falls out:
T=0ms RENDER PHASE
- Component runs; effects stored in fiber.updateQueue.lastEffect
- Fiber marked with PassiveEffect flag
- NO effects executed yet
T=5ms COMMIT PHASE begins
Phase 2a: commitBeforeMutationEffects (read old DOM)
Phase 2b: commitMutationEffects (DOM updated)
root.current = finishedWork (tree swap)
Phase 2c: commitLayoutEffects
useLayoutEffect callback called SYNCHRONOUSLY
console.log('useLayoutEffect 1') <-- EXECUTED
(main thread blocked, browser cannot paint)
scheduleCallback(NormalPriority, flushPassiveEffects)
T=7ms COMMIT PHASE COMPLETE — main thread released
T=8ms BROWSER PAINT — user sees updated UI
T=10ms SCHEDULER fires flushPassiveEffects()
commitPassiveUnmountEffects (cleanups from previous render)
commitPassiveMountEffects
console.log('useEffect 1') <-- EXECUTED
console.log('useEffect 2') <-- EXECUTED
// Output order:
// useLayoutEffect 1 (T=5ms, during commit, before paint)
// useEffect 1 (T=10ms, after paint)
// useEffect 2 (T=10ms, after paint)
Why passive effects always run at Normal priority
Notice the priority on that scheduled callback: NormalSchedulerPriority. Passive effects are always scheduled at Normal — even when the render that produced them was high-priority, such as a SyncLane click. This keeps effects from starving more urgent interactive work that arrives right after the commit.
The real code
// ReactFiberWorkLoop.js
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
});
// Passive effects always run at Normal priority regardless of the
// triggering render's lane — they cannot block urgent updates.
The practical consequence: a useEffect triggered by a high-priority click will still let the browser handle the next frame before running. A useLayoutEffect triggered by the same click will block that frame.
The HasEffect bit: deps are compared once, in render
So an effect is collected during render and fired later. But on a re-render, how does React decide whether to fire it again? You wrote a dependency array, like [userId]. Something has to compare the old deps to the new deps and reach a yes/no decision.
That comparison happens exactly once — during render — and the answer is recorded as a single bit on the effect's tag field. Commit later reads that one bit and never looks at the deps array at all.
The real code
// Effect tag bit flags
var NoFlags$1 = 0;
var HasEffect = 1; // ← "should fire this time" bit
var Insertion = 2;
var Layout = 4;
var Passive$1 = 8;
On a re-render, updateEffectImpl runs for each effect. It compares this render's deps to the previous render's deps with areHookInputsEqual. If they are equal, it pushes the effect without HasEffect. If they changed, it sets HasEffect.
The real code
// updateEffectImpl — called on every re-render for each useEffect
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
var hook = updateWorkInProgressHook();
var nextDeps = deps === undefined ? null : deps;
var destroy = undefined;
if (currentHook !== null) {
var prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
var prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
// deps UNCHANGED — push effect WITHOUT HasEffect
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
// deps CHANGED — push effect WITH HasEffect
currentlyRenderingFiber$1.flags |= fiberFlags;
hook.memoizedState = pushEffect(HasEffect | hookFlags, create, destroy, nextDeps);
}
// Two outcomes encoded as a bit:
// deps changed → tag = HasEffect | Passive$1 (e.g. 1 | 8 = 9)
// deps unchanged → tag = Passive$1 (e.g. 8)
Now the commit side. When flushPassiveEffects walks the effects to run them, it does not re-compare deps. It just checks the bit:
The real code
function commitHookEffectListMount(flags, finishedWork) {
var updateQueue = finishedWork.updateQueue;
var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
var firstEffect = lastEffect.next;
var effect = firstEffect;
do {
if ((effect.tag & flags) === flags) { // ← bit check, NOT deps comparison
var create = effect.create;
effect.destroy = create(); // run the effect
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
// No prevDeps access. No areHookInputsEqual call. Just: tag & HasEffect.
So the two phases share one effect object and communicate through one bit. Render writes the bit; commit reads it.
RENDER PHASE COMMIT PHASE ───────────── ───────────── walk hook chain walk effect ring ↓ ↓ compare prevDeps vs nextDeps effect.tag & HasEffect? ↓ ↓ deps changed? yes → run create() ↓ ↓ pushEffect(HasEffect | Passive) no → skip ↓ same effect object ───────────────────▶ same effect object (via hook.memoizedState) (via fiber.updateQueue.lastEffect)
Why split it this way? Render can be interrupted and restarted in concurrent mode — if it is, the deps simply get compared again and the bit is overwritten with the latest result, which is fine because render is repeatable. Commit must be fast and uninterruptible, so a single tag & flags test is the cheapest possible decision.
Show the full source: how the effect object is built (pushEffect)
Both mountEffectImpl and updateEffectImpl call pushEffect to create the effect object and link it into a circular list on the fiber. The list is circular so React can start at lastEffect.next (the first effect) and iterate every effect without a separate head pointer or a count.
// pushEffect — called by mountEffectImpl / updateEffectImpl
function pushEffect(tag, create, destroy, deps) {
var effect = {
tag: tag, // Passive$1, Layout, HasEffect, etc.
create: create, // Your effect function (a closure)
destroy: destroy,// Cleanup returned by the previous run
deps: deps, // Dependency array
next: null // Circular link (filled in below)
};
var componentUpdateQueue = currentlyRenderingFiber$1.updateQueue;
if (componentUpdateQueue === null) {
// First effect on this fiber: create queue, point effect at itself
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber$1.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect; // Circular!
} else {
var lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// Insert new effect between lastEffect and firstEffect
var firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect; // Complete the circle
componentUpdateQueue.lastEffect = effect; // New tail
}
}
return effect; // Returned and stored in hook.memoizedState
}
Here is the classic trap that follows from that bit. The dependency array is empty, but the effect reads a prop:
function MyComponent({ userId }) {
const [data, setData] = useState(null)
useEffect(() => {
async function fetchData() {
const res = await fetch(`/api/user/${userId}`)
const json = await res.json()
setData(json)
}
fetchData()
return () => console.log('cleanup', userId)
}, []) // ← THE BUG: empty deps
}
When userId changes from "alice" to "bob", a new closure is created that captures "bob" — but updateEffectImpl compares [] to []:
// updateEffectImpl with deps = []
var prevDeps = prevEffect.deps; // prevDeps = []
var nextDeps = []; // nextDeps = []
if (areHookInputsEqual(nextDeps, prevDeps)) {
// areHookInputsEqual: loop runs 0 times (empty), returns true
// Effect pushed WITHOUT HasEffect
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return; // ← BAILS OUT — effect with userId="bob" won't run
}
An empty array has length 0, so areHookInputsEqual loops zero times and returns true. The HasEffect bit is never set, the commit-phase bit check skips the new effect, and the fetch for "bob" never fires. The fix is to list what the effect reads — }, [userId]); — so the bit gets set when userId changes.