The React Lifecycle From the Inside: When Things Actually Run
Series: How React Works Under the Hood
Part 1: Motivation Behind React Fiber: Time Slicing & Suspense
Part 2: Why React Had to Build Its Own Execution Engine
Part 3: How React Finds What Actually Changed
Part 4: The Idea That Makes Suspense Possible
Prerequisites: Read Parts 1–4 first.
A Puzzle Most React Developers Get Wrong
Quick quiz. What order do these log?
function App() {
useLayoutEffect(() => {
console.log('layout effect');
});
useEffect(() => {
console.log('effect');
});
console.log('render');
return <div />;
}
Most people guess: render → effect → layout effect, or render → layout effect → effect.
The answer is: render → layout effect → effect.
But more importantly — why? Why does useLayoutEffect run before useEffect? Why does useEffect run at all after the component already returned? What does "after the browser paints" actually mean, and is that even always true?
This article answers all of that. And by the end, you'll be able to look at any component and predict exactly when each piece of it runs — and why.
The Three Phases of a React Render
Before we get into effects, we need to remember the pipeline from Part 2. Every React update goes through three phases:
Render — React runs your component functions, diffs the trees, figures out what changed. Nothing is written to the DOM yet. This is where console.log('render') fires.
Commit — React takes everything it figured out during Render and applies it to the real DOM. This is synchronous and uninterruptible.
Effects — After the DOM is updated, React runs your effects.
The key insight is that effects are not part of rendering. They're a separate step that happens after the DOM is already updated. This is why you can safely read the DOM inside an effect — by the time it runs, the DOM reflects the current state.
What useEffect Actually Is
Here's the mental model most developers have: useEffect is "code that runs after render." That's roughly right but missing the important details.
Here's the more accurate model: useEffect is a declaration that you have side effects to run after React is done with the DOM. You're not scheduling a callback — you're telling React about work that needs to happen, and React decides when to run it.
The distinction matters because React doesn't run effects immediately after commit. It schedules them.
From jser.dev's lifecycle article — here's what actually happens:
After the Commit phase finishes updating the DOM, React schedules your useEffect callbacks as a separate task in the Scheduler's queue — the same Scheduler we covered in Part 2. That means effects run in a new macro task, after the browser has had a chance to paint the updated screen.
This is why the React docs say useEffect runs "after the browser paints." The sequence looks like this:
1. Render phase — your component functions run
2. Commit phase — DOM is updated
3. Browser paints the screen
4. Scheduler fires — useEffect callbacks run
The browser gets step 3 because steps 1-2 are one macro task, and step 4 is a new macro task. Between any two macro tasks, the browser can paint.
The Cleanup: Why It Runs Before the Next Effect
Every useEffect can return a cleanup function:
useEffect(() => {
const subscription = subscribe(id);
return () => subscription.unsubscribe(); // cleanup
}, [id]);
When id changes and the effect needs to re-run, React runs the cleanup of the previous effect first, then runs the new effect. Always in that order.
This is also true on unmount — the cleanup runs when the component leaves the tree.
The reason is straightforward: React never wants two instances of the same effect active at the same time. Before setting up the new subscription, it tears down the old one. This is React being deliberate about side effects — always clean up before you set up again.
From jser.dev's effect lifecycle article: cleanups run first in commitPassiveUnmountEffects, then new effects run in commitPassiveMountEffects. Both happen in the same scheduled task, in the same order as the tree (children before parents, same as completeWork).
useLayoutEffect: The Synchronous Version
Here's the critical difference: useLayoutEffect runs synchronously inside the Commit phase, before the browser paints.
The sequence with useLayoutEffect:
1. Render phase — component functions run
2. Commit phase — DOM is updated
3. useLayoutEffect callbacks run ← here, synchronously, before paint
4. Browser paints
5. useEffect callbacks run ← here, in next macro task
This is why useLayoutEffect fires before useEffect in our opening quiz — it's not "earlier in the same phase," it's in a completely different phase.
And this is why useLayoutEffect exists at all. If you need to read the DOM after it's updated but before the user sees it — for example, measuring an element's size to position a tooltip — useLayoutEffect is the only place where that's safe and accurate.
// useLayoutEffect — correct for DOM measurements
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setTooltipPosition({ top: rect.bottom, left: rect.left });
});
// useEffect — would cause a visible flicker for measurements
// because the browser already painted before this runs
useEffect(() => {
const rect = ref.current.getBoundingClientRect();
setTooltipPosition({ top: rect.bottom, left: rect.left }); // too late
});
If you use useEffect for a DOM measurement that affects layout, the user will briefly see the wrong layout (before useEffect runs), then see it jump to the correct layout (after). That flicker is exactly what useLayoutEffect prevents.
The "After Paint" Rule Has an Exception
Here's something jser.dev discovered that React's own documentation gets wrong.
The docs say useEffect always runs after the browser paints. But that's not strictly true.
Sometimes React runs useEffect callbacks before paint. This happens when React determines it's more important to show the latest UI as quickly as possible — for example, when a re-render is triggered by a user interaction, or when effects are scheduled under layout effects. In those cases, React won't wait for a paint before running the effects.
From jser.dev's paint timing article:
"I'd explain the timing of running useEffect() callbacks as follows. useEffect() callbacks are run after DOM mutation is done. Most of the time, they are run asynchronously after paint, but React might run them synchronously before paint when it is more important to show the latest UI — for example when re-render is caused by user interactions or scheduled under layout effects, or simply if React has the time internally to do so."
The practical implication: never rely on useEffect to run after paint for correctness. If your code requires running after the browser paints, useLayoutEffect with its explicit synchronous-before-paint guarantee is actually the more predictable choice. And if you truly need to run something after paint for non-DOM reasons, the gap is usually small enough not to matter.
The Dependency Array: What It Actually Controls
The dependency array in useEffect and useLayoutEffect doesn't control whether the effect runs — it controls when it re-runs.
React compares the current values of the dependency array to the previous ones using Object.is (similar to === but handles NaN and -0 correctly). If any value changed, React marks the effect with a flag indicating it should run again. If nothing changed, the effect is skipped this cycle.
useEffect(() => {
fetchUser(id);
}, [id]); // only re-runs when id changes
Three cases:
No dependency array — re-runs after every render. The effect has no conditions.
Empty array [] — runs once on mount, cleanup runs on unmount. Effect has no dependencies that can change.
Array with values — re-runs whenever any listed value changes between renders.
The important thing to understand: React doesn't track what you use inside the effect. It trusts the dependency array you provide. If you use userId inside the effect but forget to put it in the array, React won't re-run the effect when userId changes — and you get a stale value bug. This is why eslint-plugin-react-hooks exists: it statically analyzes your effect and warns when the array is incomplete.
The Full Lifecycle in One Timeline
The pattern is consistent across every scenario. On mount, the component renders, React commits the DOM, useLayoutEffect fires synchronously before the browser paints, the browser paints, then useEffect runs in the next macro task. On update, cleanups always run before new effects — layout cleanup first, then layout create, then paint, then passive cleanup, then passive create. On unmount, both cleanups run in the same order, layout before passive, with paint in between.
Two things are always true regardless of the scenario: layout effects are synchronous and pre-paint, passive effects are scheduled and post-commit. And cleanups always run before the next effect of the same type — React never has two instances of the same effect active at once.
When to Use Which
Use useEffect for everything by default. Most side effects — data fetching, subscriptions, logging, timers — don't need to happen before the browser paints. Running them after paint keeps the UI responsive and is the right choice in the vast majority of cases.
Reach for useLayoutEffect only when you need to read or modify the DOM before the user sees it — measuring element dimensions, positioning a tooltip relative to another element, or preventing a visual flicker. The practical test is simple: if swapping useLayoutEffect for useEffect causes a visible jump or flash in the UI, you needed useLayoutEffect. If it looks identical, stick with useEffect.
What's Coming in Part 6
In Part 6 we go inside useState — how state is stored on the Fiber, what happens when you call setState, and why calling setState multiple times in one event handler doesn't cause multiple re-renders.
🎬 Watch These
JSer (jser.dev) — The lifecycle of effect hooks in React
The source for the Effect object internals, HookHasEffect tag, flushPassiveEffects, and cleanup ordering in this article.
JSer (jser.dev) — How does useLayoutEffect() work internally?
The synchronous commit-phase timing of layout effects vs passive effects — the source for the timing difference explained here.
JSer (jser.dev) — When do useEffect() callbacks get run? Before paint or after paint?
The surprising finding that React.dev's "always after paint" description is inaccurate — and the real timing rules.
🙏 Sources & Thanks
jser.dev — all timing mechanics in this article come from JSer's source-level analysis. Articles used: The lifecycle of effect hooks in React, How does useLayoutEffect() work internally?, How does useEffect() work internally in React?, and When do useEffect() callbacks get run? — including the quote about React.dev's inaccurate description.
React source —
ReactFiberCommitWork.js(commitLayoutEffects,commitPassiveMountEffects,commitPassiveUnmountEffects) andReactFiberWorkLoop.js(scheduleCallbackfor passive effects).Lydia Hallie — for JavaScript visualizations that shaped this series' style.
Part 6 is next — how useState actually works: where state lives, what happens when you call setState, and why multiple calls in one handler don't cause multiple re-renders. 🔧
Tags: #react #javascript #webdev #tutorial
This article was originally published by DEV Community and written by Sam Abaasi.
Read original article on DEV Community


