Why the moment of execution decides between visible flicker and a clean paint
useEffect runs after the paint, useLayoutEffect synchronously before it. A tiny timing difference on paper, one that in practice decides whether a DOM measurement for a tooltip or a scroll position visibly flickers or not.
Table of Contents
- 1. The Rendering Pipeline in Three Phases
- 2. useEffect: Running After the Paint
- 3. useLayoutEffect: Synchronous Before the Paint
- 4. A Concrete Flicker Scenario
- 5. The Fix Using useLayoutEffect
- 6. Server Side Rendering and the useLayoutEffect Warning
- 7. Other Sensible Use Cases
- 8. Rule of Thumb: When useLayoutEffect Is Truly Needed
- 9. Common Mistakes with useLayoutEffect
- 10. Summary
- 11. FAQ
1. The Rendering Pipeline in Three Phases
React's rendering pipeline runs through three consecutive phases: in the render phase, React computes the new virtual tree, in the commit phase, React actually applies the resulting changes to the real DOM, and in the paint phase, the browser finally draws those DOM changes as pixels on screen.
useEffect and useLayoutEffect hook in at different points relative to that third phase. useEffect runs asynchronously after the paint, useLayoutEffect on the other hand runs synchronously after the commit, but before the browser paints anything at all. That seemingly small difference decides in practice whether a DOM measurement causes visible flicker for the user or not.
2. useEffect: Running After the Paint
useEffect schedules its callback execution through the browser's scheduling queue. The browser is allowed to paint the current frame in the meantime, before the effect even runs, which keeps the main thread responsive for the user throughout. For data fetching, subscriptions, event listener registration, or logging, effects without immediate visual impact, that is the ideal and recommended default.
The cost of this non-blocking behavior shows up as soon as a DOM property is read inside useEffect and used to change the layout, say the actual height of a tooltip that was just rendered. By that point the browser has already painted the old, still incorrect state, so the following correction is perceived as a brief but clearly visible flicker.
function NaiveTooltip({ triggerRef, children }) {
const [top, setTop] = useState(0);
useEffect(() => {
// This measurement runs AFTER the first visible paint,
// the tooltip briefly appears in the wrong spot and then jumps
const rect = triggerRef.current.getBoundingClientRect();
setTop(rect.bottom + 8);
}, [triggerRef]);
return <div style={{ position: 'fixed', top }}>{children}</div>;
}
3. useLayoutEffect: Synchronous Before the Paint
useLayoutEffect runs synchronously, right after React applies the DOM mutations during the commit phase, but before the browser paints the next frame. React deliberately delays the paint until the callback function has fully completed.
That makes it possible to run measurements like getBoundingClientRect() or offsetHeight inside useLayoutEffect, and a resulting setState call gets processed before the visible frame. The user never gets to see the intermediate state with the wrong position, because it never gets painted.
function CorrectTooltip({ triggerRef, children }) {
const [top, setTop] = useState(0);
useLayoutEffect(() => {
// This measurement runs BEFORE the visible paint,
// the resulting setState call is processed in the same cycle
const rect = triggerRef.current.getBoundingClientRect();
setTop(rect.bottom + 8);
}, [triggerRef]);
return <div style={{ position: 'fixed', top }}>{children}</div>;
}
4. A Concrete Flicker Scenario
A tooltip should appear directly above its trigger element, but its actual height is only known once it has rendered at least once. With useEffect, the tooltip therefore first appears at an assumed, still incorrect position and then visibly jumps to its actual correct spot once the measurement completes.
Depending on screen refresh rate and the complexity of the tooltip content, this jump can be perceived as noticeable, distracting flicker. It becomes especially apparent with rapid consecutive interactions, say hovering over several list items in a row, where several such position jumps become visible in quick succession.
5. The Fix Using useLayoutEffect
Move that same measurement from useEffect into useLayoutEffect, and React computes the correct position within the same commit cycle. The resulting setState call triggers an additional, but still synchronous, render and commit pass before the browser draws anything on screen at all.
The user therefore sees the correctly positioned tooltip directly, with no visible intermediate state and no perceptible jump. The trade-off is that React delays the paint for this extra synchronous cycle, which can become noticeable with expensive measurements or deeply nested component trees and should therefore be used deliberately, not by default.
function Tooltip({ triggerRef, children }) {
const tooltipRef = useRef(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
let top = triggerRect.bottom + 8;
if (top + tooltipRect.height > window.innerHeight) {
top = triggerRect.top - tooltipRect.height - 8; // flip above instead
}
setPos({ top, left: triggerRect.left });
}, [triggerRef]);
return (
<div ref={tooltipRef} style={{ position: 'fixed', top: pos.top, left: pos.left }}>
{children}
</div>
);
}
6. Server Side Rendering and the useLayoutEffect Warning
No DOM and no paint cycle exist on the server. React therefore emits a warning for useLayoutEffect inside a server rendered component, to the effect of: useLayoutEffect does nothing on the server. The effect simply never runs there, because there's no browser sense commit moment for useLayoutEffect to hook into.
A common pattern instead is a custom useIsomorphicLayoutEffect hook that falls back to useEffect on the server, which also doesn't run there but produces no warning, and uses regular useLayoutEffect in the browser. The decision is usually made via a check of typeof window !== 'undefined' at module import time.
import { useEffect, useLayoutEffect } from 'react';
// Falls back to useEffect on the server (no warning),
// uses the synchronous useLayoutEffect in the browser
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
7. Other Sensible Use Cases
Besides tooltip positioning, useLayoutEffect is useful for saving and restoring scroll position around a DOM update, say for a chat list that inserts new messages at the top, where the existing scroll position would otherwise visibly jump upward. Synchronously reading ResizeObserver measurements before the first visible frame falls into this category too.
Another classic case is CSS transition triggers, where a starting state has to be set, the DOM committed, and immediately afterward, in the same frame, the target state set, so the browser actually recognizes the transition as an animation rather than a jump and plays it correctly.
function ChatList({ messages }) {
const listRef = useRef(null);
const prevHeight = useRef(0);
useLayoutEffect(() => {
const el = listRef.current;
const newHeight = el.scrollHeight;
// Shift scroll position by exactly the newly inserted height,
// before the browser paints the new state
el.scrollTop += newHeight - prevHeight.current;
prevHeight.current = newHeight;
}, [messages]);
return <div ref={listRef} className="chat-list">{/* ... */}</div>;
}
8. Rule of Thumb: When useLayoutEffect Is Truly Needed
If nothing inside the effect reads from the DOM in a way meant to synchronously trigger another layout update, useEffect is entirely sufficient. The vast majority of effects in a typical application, data fetching, subscriptions, event listener registration, analytics, and logging, fall into this category and benefit from useEffect keeping the main thread unblocked.
useLayoutEffect is only truly necessary when a visible intermediate state, be it flicker or a visible jump, would otherwise be unavoidable, because a DOM measurement directly influences the result of a synchronous state update. In every other case, using useLayoutEffect merely costs unnecessary perceived rendering performance without offering any real benefit.
9. Common Mistakes with useLayoutEffect
The most common mistake is using useLayoutEffect across the board instead of useEffect, without any real layout measurement actually taking place. That unnecessarily blocks the paint on every commit, which noticeably worsens the perceived responsiveness of the entire application for complex, deeply nested component trees, without producing a single visible benefit.
A second common mistake is asynchronous operations such as fetch calls or timers inside useLayoutEffect, which completely defeats its synchronous advantage, since the browser has to wait for the asynchronous operation anyway. On top of that, missing cleanup for measurements tied to resize or scroll events is common, leaving stale listeners quietly running after the component unmounts.
| Criterion | useEffect | useLayoutEffect | Recommendation |
|---|---|---|---|
| Execution timing | Asynchronous, after the paint | Synchronous, after commit, before the paint | Choose useEffect as the default |
| Blocks the browser paint | No | Yes, until the callback completes | Use useLayoutEffect sparingly |
| Behavior with server side rendering | Doesn't run on the server, no warning | Doesn't run on the server, produces a warning | Use useIsomorphicLayoutEffect for SSR projects |
| Typical use case | Data fetching, subscriptions, logging | DOM measurement with an immediately following layout update | Decide based on actual need, not by default |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
useLayoutEffect vs. useEffect: The Essentials at a Glance
Timing
useEffect runs asynchronously after the paint, useLayoutEffect synchronously after commit, but before the paint.
Flicker
DOM measurements with an immediately following layout update belong in useLayoutEffect to avoid visible jumps.
SSR
useLayoutEffect produces a warning on the server, useIsomorphicLayoutEffect solves the problem cleanly.
Rule of Thumb
Without a real layout measurement, useEffect suffices, useLayoutEffect is the exception, not the default.