useLayoutEffect vs useEffect
The Timing Difference
This is one of those things that seems like a tiny detail until you're staring at a flickering tooltip. Both hooks run after React commits DOM changes. The difference is when they run relative to the browser paint:
Render → Commit (DOM update) → useLayoutEffect → Browser Paint → useEffect
function Component() {
useLayoutEffect(() => {
// Runs BEFORE the browser paints.
// You can read DOM and synchronously modify it.
// The user never sees the intermediate state.
});
useEffect(() => {
// Runs AFTER the browser paints.
// The user has already seen the DOM.
// If you modify DOM here, they see a flicker.
});
}
Think of the browser paint as a curtain reveal on a stage. useLayoutEffect runs while the curtain is still closed — you can rearrange the furniture and the audience never sees the mess. useEffect runs after the curtain opens — if you move furniture now, the audience sees it jump. For most work, post-curtain (useEffect) is fine. But for DOM measurements and synchronous visual adjustments, you need pre-curtain (useLayoutEffect).
When useLayoutEffect Is Required
So when do you actually need this? Three main scenarios.
1. Measuring DOM Before Paint
function Tooltip({ targetRef, children }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setPosition({
top: targetRect.top - tooltipRect.height - 8,
left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
});
}, [targetRef]);
return (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: position.top,
left: position.left,
}}
>
{children}
</div>
);
}
If this used useEffect, the tooltip would briefly appear at (0, 0) before jumping to the correct position — a visible flicker. useLayoutEffect measures and repositions before the user sees anything.
2. Preventing Visual Flicker
function AnimatedHeight({ isOpen, children }) {
const contentRef = useRef(null);
const [height, setHeight] = useState(0);
useLayoutEffect(() => {
if (isOpen) {
setHeight(contentRef.current.scrollHeight);
} else {
setHeight(0);
}
}, [isOpen]);
return (
<div style={{ height, overflow: 'hidden', transition: 'height 300ms' }}>
<div ref={contentRef}>{children}</div>
</div>
);
}
3. Scroll Restoration
function ChatMessages({ messages }) {
const containerRef = useRef(null);
useLayoutEffect(() => {
// Scroll to bottom before user sees the new message
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [messages.length]);
return (
<div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
{messages.map(msg => <Message key={msg.id} message={msg} />)}
</div>
);
}
Why useLayoutEffect blocks paint
useLayoutEffect is synchronous. After React commits DOM changes, it runs all useLayoutEffect callbacks before yielding to the browser's paint cycle. If your useLayoutEffect is slow (heavy computation, network request), it blocks the paint — the user sees nothing until it completes. This is why React warns against expensive work in useLayoutEffect. Use it only for synchronous DOM reads/writes that must happen before paint.
The SSR Warning
One more gotcha: useLayoutEffect does not run on the server because there is no DOM. React logs a warning in SSR:
Warning: useLayoutEffect does nothing on the server
Solutions:
// Option 1: Suppress by using useEffect with initial state
function Tooltip({ children }) {
const [position, setPosition] = useState(null);
useLayoutEffect(() => {
// Measure and position
setPosition(calculatePosition());
}, []);
if (!position) return null; // Render nothing until measured
return <div style={position}>{children}</div>;
}
// Option 2: Use dynamic import for client-only components
// Option 3: Custom hook that falls back to useEffect on server
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
The useIsomorphicLayoutEffect pattern is common in libraries but hides a real issue: if the effect's purpose is DOM measurement, using useEffect on the server is a no-op (correct) but using useEffect on the client introduces flicker (wrong). The real fix is often to avoid the layout effect entirely by using CSS for positioning, or to accept a loading state before measurement.
Side-by-Side Comparison
| Feature | useEffect | useLayoutEffect |
|---|---|---|
| Timing | After browser paint | Before browser paint |
| Blocking | Non-blocking | Blocks paint |
| DOM visible | User has seen DOM | User has not seen DOM yet |
| Use case | Data fetching, subscriptions, logging | DOM measurement, preventing flicker |
| SSR | Works (no-op if no DOM) | Warning on server |
| Performance | Better — does not block paint | Can hurt — blocks paint |
| Default choice | Yes — use this unless you see flicker | No — escape hatch for visual issues |
Production Scenario: Auto-Resizing Textarea
function AutoResizeTextarea({ value, onChange }) {
const textareaRef = useRef(null);
useLayoutEffect(() => {
const textarea = textareaRef.current;
textarea.style.height = '0px'; // Reset to measure scrollHeight
textarea.style.height = `${textarea.scrollHeight}px`;
}, [value]);
return (
<textarea
ref={textareaRef}
value={value}
onChange={onChange}
style={{ overflow: 'hidden', resize: 'none' }}
/>
);
}
With useEffect, the textarea would flash at 0px height before growing — visible as a flicker. useLayoutEffect adjusts height before the browser paints, making the resize invisible.
| What developers do | What they should do |
|---|---|
| Using useLayoutEffect for data fetching useLayoutEffect blocks the browser paint. Slow network requests freeze the UI. Data fetching should happen asynchronously after paint. | Use useEffect — data fetching does not need to block paint |
| Using useEffect for DOM measurement that causes flicker useEffect runs after paint. If you measure DOM and setState in useEffect, the user sees the initial state briefly before the corrected state — a visual flicker. | Use useLayoutEffect to measure and reposition before the user sees anything |
| Using useLayoutEffect everywhere 'just to be safe' useLayoutEffect blocks paint synchronously. Overuse delays the initial visual update and hurts perceived performance. | Default to useEffect. Switch to useLayoutEffect only when you observe visual flicker. |
| Ignoring the SSR warning for useLayoutEffect useLayoutEffect on the server causes hydration warnings and does nothing. Handle the server case explicitly. | Use useIsomorphicLayoutEffect or restructure to avoid layout effects |
- 1useEffect runs after paint — default choice for most effects
- 2useLayoutEffect runs before paint — use only for DOM measurement and preventing flicker
- 3useLayoutEffect blocks paint — keep it fast, no network requests or heavy computation
- 4useLayoutEffect does not run on the server — use useIsomorphicLayoutEffect for SSR
- 5If you do not see visual flicker with useEffect, you do not need useLayoutEffect
Challenge: Fix the Tooltip Flicker
Challenge: Tooltip Positioning
// This tooltip flickers — it appears at (0,0) briefly before
// jumping to the correct position. Fix it.
function Tooltip({ anchorRef, children }) {
const tooltipRef = useRef(null);
const [coords, setCoords] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!anchorRef.current || !tooltipRef.current) return;
const anchorRect = anchorRef.current.getBoundingClientRect();
setCoords({
top: anchorRect.bottom + 8,
left: anchorRect.left,
});
}, [anchorRef]);
return (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
}}
>
{children}
</div>
);
}
Show Answer
Change useEffect to useLayoutEffect:
function Tooltip({ anchorRef, children }) {
const tooltipRef = useRef(null);
const [coords, setCoords] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
if (!anchorRef.current || !tooltipRef.current) return;
const anchorRect = anchorRef.current.getBoundingClientRect();
setCoords({
top: anchorRect.bottom + 8,
left: anchorRect.left,
});
}, [anchorRef]);
return (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
}}
>
{children}
</div>
);
}useLayoutEffect runs before the browser paints. The tooltip is measured and repositioned synchronously — the user never sees it at (0, 0). The only user-visible state is the final correct position.