How useLayoutEffect works in React and how does it differ from useEffect?
useLayoutEffect runs synchronously after React commits DOM changes but before the browser paints the screen. useEffect runs asynchronously after paint. That timing difference is the whole point.
Theory
TL;DR
useLayoutEffectis like proofreading a letter before sealing the envelope.useEffectis checking what you sent after delivery.- Main difference:
useLayoutEffectblocks browser paint until it finishes.useEffectdoes not. - Reading or modifying the DOM before the user sees it?
useLayoutEffect. Data fetching, subscriptions, analytics?useEffect. useLayoutEffectis a no-op during SSR (no DOM).useEffectruns normally.- In React 18,
useLayoutEffectstill runs at immediate priority and can block concurrent transitions.
Quick example
import { useEffect, useLayoutEffect, useRef } from "react";
function Demo() {
const ref = useRef(null);
useLayoutEffect(() => {
// Runs BEFORE paint - user never sees this intermediate state
if (ref.current) ref.current.style.background = "red";
});
useEffect(() => {
// Runs AFTER paint - overwrites the layout effect
if (ref.current) ref.current.style.background = "blue";
});
return <div ref={ref} style={{ width: 100, height: 100, background: "green" }} />;
}
// Visible result: blue box.
// Order: green (JSX) -> red (layout effect, pre-paint) -> blue (effect, post-paint).
// The user only ever sees blue - no flicker.Both effects fire on every render here (no dependency array). The layout effect sets red, but that change never reaches the screen because paint is still blocked. The passive effect fires after paint and overwrites to blue.
Key difference
useLayoutEffect hooks into React's commit phase at commitLayoutEffects, which runs synchronously and blocks requestAnimationFrame until it completes. So you can call getBoundingClientRect() and get values that reflect the latest committed DOM state. useEffect is scheduled as a passive effect at lower priority, after the browser has already painted. Any DOM read inside useEffect may reflect already-rendered values, which produces the visual jump you've probably seen with tooltips or popovers that reposition themselves on first render.
When to use useLayoutEffect vs useEffect
- Measure element dimensions before the user sees them:
useLayoutEffect. Avoids the flash where an element jumps from one position to another. - Read scroll position or text selection after a commit:
useLayoutEffect. The data matches the current DOM state, not a stale snapshot. - Position tooltips, dropdowns, or popovers:
useLayoutEffect. Reading layout and writing back in the same synchronous window prevents flicker. - Data fetching, subscriptions, analytics, timers:
useEffect. These do not need pre-paint access. Blocking paint for them just wastes render time.
Comparison table
| Aspect | useLayoutEffect | useEffect |
|---|---|---|
| Timing | Sync, post-commit, pre-paint | Async, post-paint |
| Blocks browser paint? | Yes | No |
| Best for | DOM measurements, style corrections | API calls, subscriptions, logging |
| SSR behavior | Skipped entirely (no DOM) | Runs normally |
| Performance cost | Higher (blocks UI thread) | Lower (non-blocking) |
| Strict Mode in dev | Double-invoked | Double-invoked |
| React 18 concurrent mode | Immediate priority, can block transitions | Passive, deferred |
How React schedules these internally
React's fiber reconciler runs useLayoutEffect during commitLayoutEffects at ImmediateSchedulerPriority. It fires in a synchronous loop over fiber roots, completing before requestAnimationFrame gets a slot. useEffect goes into commitPassiveMountEffects, scheduled via scheduleCallback(NormalSchedulerPriority). Passive and deferred, so the browser paints first, then React processes the passive effects queue.
But there is a catch. In React 18 concurrent mode, useLayoutEffect still runs at immediate priority. Heavy work inside it can delay transitions started by useTransition, because it does not yield to deferred updates.
In practice, I have seen more than a few codebases where useLayoutEffect was used for data fetching. The network request is async either way, so it does not help. But it adds blocking time to every render cycle. Open Chrome DevTools Performance tab and record a render - you'll see it clearly labeled in the timeline.
Common mistakes with useLayoutEffect
Fetching data inside useLayoutEffect
// Wrong - blocks paint for no reason
useLayoutEffect(() => {
fetch('/api/data').then(setData);
}, []);
// Right
useEffect(() => {
fetch('/api/data').then(setData);
}, []);The fetch is async regardless. useLayoutEffect does not make it faster. It just delays the first paint.
Ignoring server-side rendering
// Logs a warning in SSR - no DOM on the server
useLayoutEffect(() => {
document.title = 'My App';
}, []);
// Safe alternatives:
useEffect(() => { document.title = 'My App'; }, []);
// Or guard it:
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
document.title = 'My App';
}, []);Next.js and Remix skip useLayoutEffect on the server but log a dev warning. If you see that warning, switch to useEffect or add the typeof window guard.
Unstable dependencies cause infinite loops
// Infinite loop - new object reference on every render
useLayoutEffect(() => {
applyStyle(elementRef.current, style);
}, [style]); // style = { color: 'red' } is recreated each render
// Fix with useMemo
const style = useMemo(() => ({ color: 'red' }), []);
useLayoutEffect(() => {
applyStyle(elementRef.current, style);
}, [style]);This is not unique to useLayoutEffect, but the synchronous re-render batching makes the loop tighter and harder to spot.
Using it for animations that run on every frame
// Blocks requestAnimationFrame on every value change
useLayoutEffect(() => {
gsap.to(element, { x: 100 });
}, [value]);
// Better - let the animation library own the frame loop
useEffect(() => {
gsap.to(element, { x: 100 });
}, [value]);Blocking requestAnimationFrame inside a layout effect causes frame drops. Animation libraries manage their own scheduling - do not interrupt that.
Real-world usage
- React Window / React Virtual: measures row heights before virtualizing scroll using
useLayoutEffectso the initial render shows correct scroll offsets. - Framer Motion: reads DOM bounds pre-paint for layout animations (the
layoutprop internally). - Floating UI / React Tooltip: all tooltip and popover positioning runs in
useLayoutEffectso elements appear at the correct coordinates on first render. - TanStack Table: header resize measurements go through
useLayoutEffectto avoid column-width flicker during drag. - React Spring: uses layout effects for synchronous style reads in physics-based animations.
Follow-up questions
Q: What is the exact execution order of useLayoutEffect and useEffect including cleanups on re-render?
A: On mount: layout effect runs, then passive effect runs. On re-render: layout cleanup fires, layout effect fires, then passive cleanup fires, then passive effect fires. Layout always precedes passive. This matters when one effect depends on state set by the other.
Q: What happens to useLayoutEffect in SSR (Next.js, Remix)?
A: React skips it and logs a dev warning. The component renders, but the effect never runs on the server. Use useEffect for server-safe side effects.
Q: In React 18 Strict Mode, how many times does useLayoutEffect fire?
A: Twice in development. React mounts, runs the effect, runs the cleanup, then remounts. This surfaces missing cleanup logic. In production it fires once.
Q: What is useInsertionEffect and how does it compare?
A: useInsertionEffect (React 18+) fires before DOM mutations. It was designed for CSS-in-JS libraries that inject stylesheets and cannot read layout at all. useLayoutEffect fires after mutations and can read layout. For general DOM work, useLayoutEffect is still the right choice.
Q (senior): Can useLayoutEffect block concurrent transitions in React 18?
A: Yes. It runs at ImmediateSchedulerPriority, so it executes before React yields to deferred updates from useTransition. Heavy synchronous logic inside it will delay transition completion and visibly slow the UI.
Examples
Tooltip positioning without flicker
This is the canonical use case. Without useLayoutEffect, the tooltip briefly renders at position {top: 0, left: 0} and then jumps to the correct coordinates.
import { useLayoutEffect, useState, useRef } from "react";
function Tooltip({ children, text }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
useLayoutEffect(() => {
const trigger = triggerRef.current.getBoundingClientRect();
const tooltip = tooltipRef.current.getBoundingClientRect();
// Calculate position pre-paint so no jump on first render
setPosition({
top: trigger.bottom + 8,
left: Math.max(8, trigger.left + (trigger.width - tooltip.width) / 2),
});
}, []);
return (
<>
<span ref={triggerRef}>{children}</span>
<div
ref={tooltipRef}
style={{
position: "fixed",
top: position.top,
left: position.left,
background: "#111",
color: "#fff",
padding: "4px 8px",
borderRadius: 4,
}}
>
{text}
</div>
</>
);
}Both element rects are read synchronously, the position is calculated, and React re-renders with the correct coordinates before the browser paints. Floating UI uses this same pattern internally.
Detecting text overflow before the first paint
import { useState, useLayoutEffect, useRef } from "react";
function TruncatedLabel({ text }) {
const [isOverflowing, setIsOverflowing] = useState(false);
const ref = useRef(null);
useLayoutEffect(() => {
if (ref.current) {
// scrollWidth > clientWidth means text is clipped
setIsOverflowing(ref.current.scrollWidth > ref.current.clientWidth);
}
}, [text]);
return (
<span
ref={ref}
title={isOverflowing ? text : undefined}
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 200,
}}
>
{text}
</span>
);
}With useEffect, the component would paint once without the title attribute, then add it on the next tick. With useLayoutEffect, the measurement and re-render with the correct title attribute happen before the first paint.
Using useEffect for the common case
Not everything needs useLayoutEffect. For anything that does not involve DOM measurements or mutations before paint, useEffect is the right choice.
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; };
}, [userId]);
if (!user) return <p>Loading...</p>;
return <p>{user.name}</p>;
}Data fetching does not need pre-paint access. Using useLayoutEffect here blocks the browser's first paint while the request is in flight - extra delay, zero benefit.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.