Rendering Phases
Every React update goes through two phases. Understanding what happens in each — and what's safe to do where — prevents entire categories of bugs.
Phase 1: Render (The "What")
The render phase is React's planning stage. React calls your component functions, walks the Fiber tree, and figures out what the UI should look like.
What happens:
- Your component function executes
- React calls
useState,useReducer,useMemo,useCallback(reads current values, computes derived ones) - React compares the new output against the previous output (reconciliation)
- React marks Fibers that need DOM updates
What does NOT happen:
- The DOM is not touched
- Side effects do not run
useEffectanduseLayoutEffectcallbacks do not fire
Key constraint: render must be pure.
Because the render phase is interruptible and can be restarted, your component function might be called multiple times. It must produce the same output for the same input — no side effects.
// ❌ Side effect in render — runs multiple times, unpredictable
function Component() {
fetch('/api/data'); // called on every render, possibly multiple times
return <div />;
}
// ✅ Side effect in effect — runs once after commit
function Component() {
useEffect(() => {
fetch('/api/data');
}, []);
return <div />;
}Phase 2: Commit (The "Do")
After React knows what changed, it applies those changes. The commit phase is synchronous and uninterruptible — it must complete in one go.
The commit phase has three sub-phases:
Before Mutation
React reads the current DOM before changing it. getSnapshotBeforeUpdate (class components) runs here.
Mutation
React applies all DOM changes — inserting nodes, updating attributes, removing elements.
Layout
The DOM is updated. useLayoutEffect runs synchronously before the browser paints. This is where you can safely measure DOM elements and immediately update layout.
function Tooltip({ targetRef }) {
const tooltipRef = useRef();
useLayoutEffect(() => {
// DOM is updated, browser hasn't painted yet
const targetRect = targetRef.current.getBoundingClientRect();
tooltipRef.current.style.top = `${targetRect.bottom}px`;
// The browser will paint the tooltip in the right position
// Users never see it in the wrong position
});
return <div ref={tooltipRef} className="tooltip" />;
}After this, the browser paints, and then useEffect runs asynchronously.
The Full Sequence
The useEffect Data-Fetching Problem
Knowing the sequence reveals a common anti-pattern. When you fetch data inside useEffect:
- React renders the component with no data (loading state)
- Commits to DOM — user sees a skeleton or spinner
useEffectfires — API call goes out- Response arrives,
setStatecalled - React re-renders with data, commits again
That's two full render-commit cycles to show the user what they actually wanted to see. This was the only option for a long time, and there's nothing wrong with apps that do it — you didn't have a choice.
Suspense is the better alternative. When React hits a component wrapped in Suspense during the render phase, it fires the data request immediately and shows the fallback. If the data arrives before React finishes rendering the rest of the tree, you skip the flash entirely. And since you're dealing with the resolved value (not null | Data), TypeScript is happier too.
// Old: two renders, fighting null
function Profile() {
const [user, setUser] = useState(null);
useEffect(() => { fetchUser().then(setUser); }, []);
if (!user) return <Spinner />;
return <div>{user.name}</div>; // TypeScript: user could be null
}
// New: one render, data guaranteed
function Profile() {
const user = use(fetchUser()); // suspends until resolved
return <div>{user.name}</div>; // TypeScript: user is always User
}useEffect vs useLayoutEffect
useEffect | useLayoutEffect | |
|---|---|---|
| When it fires | After browser paint | After DOM update, before paint |
| Blocks paint? | No | Yes |
| Use for | Data fetching, subscriptions, analytics | DOM measurements, scroll sync, tooltips |
| SSR | Safe (runs on client only) | Causes a warning on server |
Default to useEffect. Only reach for useLayoutEffect when you truly need to measure the DOM before paint.
A note on useLayoutEffect: it runs synchronously inside the commit phase, which means it can block the entire commit if you do anything expensive in it. It's primarily for library authors building rendering tools. If you put an API call or any async work in there, you've re-introduced the blocking behaviour that Fiber was designed to eliminate. If you don't know that you need it, you don't need it.
Why Strict Mode Double-Invokes
In development, React's Strict Mode intentionally:
- Calls your component function twice during the render phase
- Calls
useEffectcleanup and then the effect itself again
This surfaces bugs where:
- Your render has side effects (they'll fire twice and you'll notice)
- Your effect doesn't clean up properly (the cleanup will run before the effect re-fires)
In production, this double-invocation doesn't happen. Strict Mode is a developer tool for catching impurity, not a performance concern.
The Practical Rule
| Where | What's safe |
|---|---|
| Component body (render) | Reading state, computing derived values, returning JSX |
useEffect | Data fetching, subscriptions, logging, timers |
useLayoutEffect | DOM measurements, scroll restoration, avoiding flicker |
| Event handlers | Everything — they fire outside the render cycle |
Practice what you just read.