useLayoutEffect runs synchronously after all DOM mutations but before the browser paints. This timing makes it powerful for preventing visual glitches but potentially expensive for performance. Let’s explore when you need it, when you don’t, and how to measure its impact on your application’s responsiveness.
The key insight: useLayoutEffect forces React to block the browser’s paint process until your effect completes. This can be exactly what you need—or exactly what’s killing your frame rate.
Understanding the Timing Difference
React provides two hooks for side effects that run after render: useEffect and useLayoutEffect. The difference lies in their relationship to the browser’s rendering pipeline.
function TimingDemo() {
const [count, setCount] = useState(0);
// ✅ Runs asynchronously after paint
useEffect(() => {
console.log('useEffect runs after paint');
}, [count]);
// 🟡 Runs synchronously before paint (blocking)
useLayoutEffect(() => {
console.log('useLayoutEffect runs before paint');
}, [count]);
return <div>Count: {count}</div>;
}Here’s what happens during a typical render cycle:
- React renders your component
- DOM mutations are applied
useLayoutEffectruns synchronously ← Blocks here- Browser paints the screen
useEffectruns asynchronously
This synchronous execution is both useLayoutEffect’s superpower and its performance pitfall.
When You Actually Need useLayoutEffect
Use useLayoutEffect when you need to read from or write to the DOM before the browser paints—typically to prevent visual flicker or measure elements.
Measuring DOM Elements
The most common legitimate use case is measuring elements that depend on dynamic content:
function AutoResizingTextarea() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [value, setValue] = useState('');
// ✅ Good use of useLayoutEffect - prevents visible height jumps
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
// Reset height to measure natural height
textarea.style.height = '0px';
const scrollHeight = textarea.scrollHeight;
// Apply the measured height
textarea.style.height = `${scrollHeight}px`;
}, [value]);
return (
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
style={{ resize: 'none', overflow: 'hidden' }}
/>
);
}Preventing Layout Thrashing
When you need to read and immediately write layout properties:
function ScrollSyncedHeader() {
const headerRef = useRef<HTMLElement>(null);
const [scrollY, setScrollY] = useState(0);
useLayoutEffect(() => {
const header = headerRef.current;
if (!header) return;
// Read and write in the same frame to prevent thrashing
const opacity = Math.max(0, 1 - scrollY / 100);
header.style.opacity = opacity.toString();
// Position sticky header based on scroll
header.style.transform = `translateY(${Math.max(0, scrollY - 50)}px)`;
}, [scrollY]);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return <header ref={headerRef}>Scroll-synced header</header>;
}Performance Characteristics and Costs
useLayoutEffect has measurable performance implications because it blocks the main thread during the critical rendering path.
Blocking the Paint Process
Every millisecond spent in useLayoutEffect directly delays when users see your updates:
function ExpensiveLayoutEffect() {
const [data, setData] = useState<number[]>([]);
// ❌ Bad - blocks paint for expensive computation
useLayoutEffect(() => {
// Simulating expensive DOM measurements
const measurements = [];
for (let i = 0; i < 1000; i++) {
const element = document.querySelector(`[data-item="${i}"]`);
if (element) {
measurements.push(element.getBoundingClientRect());
}
}
console.log('Blocking paint for', measurements.length, 'measurements');
}, [data]);
// ✅ Better - use useEffect unless sync timing is critical
useEffect(() => {
// Same expensive work, but doesn't block paint
// User sees the update immediately, measurements happen after
}, [data]);
}Impact on Concurrent Features
useLayoutEffect runs synchronously even in React’s Concurrent Mode, which can interfere with features like time slicing:
function ConcurrentUnfriendly() {
const [items, setItems] = useState<Item[]>([]);
// This blocks even during concurrent rendering
useLayoutEffect(() => {
// Heavy DOM work that can't be interrupted
items.forEach((item, index) => {
const element = document.querySelector(`[data-id="${item.id}"]`);
if (element) {
// Expensive layout calculations
element.style.transform = calculateTransform(item, index);
}
});
}, [items]);
}Measuring Performance Impact
Use the React DevTools Profiler and browser performance tools to measure the actual cost:
function MeasuredLayoutEffect() {
const [trigger, setTrigger] = useState(0);
useLayoutEffect(() => {
const start = performance.now();
// Your layout effect work
document.querySelectorAll('.measured-element').forEach((el) => {
const rect = el.getBoundingClientRect();
// Do something with measurements
});
const end = performance.now();
console.log(`Layout effect took ${end - start}ms`);
}, [trigger]);
return <button onClick={() => setTrigger((t) => t + 1)}>Trigger Layout Effect</button>;
}performance.mark() and performance.measure() for more sophisticated profiling that integrates with browser DevTools.
Optimization Strategies
Batch DOM Operations
When you must use useLayoutEffect, batch your DOM operations to minimize layout thrashing:
function BatchedLayoutOperations() {
const [items, setItems] = useState<Item[]>([]);
useLayoutEffect(() => {
// ❌ Bad - causes multiple layout recalculations
items.forEach((item) => {
const el = document.querySelector(`[data-id="${item.id}"]`);
if (el) {
el.style.height = `${item.height}px`; // Layout recalc
el.style.width = `${item.width}px`; // Layout recalc
}
});
// ✅ Better - batch style changes
const updates = items.map((item) => ({
element: document.querySelector(`[data-id="${item.id}"]`),
styles: { height: `${item.height}px`, width: `${item.width}px` },
}));
// Apply all changes together
updates.forEach(({ element, styles }) => {
if (element) {
Object.assign(element.style, styles);
}
});
}, [items]);
}Use ResizeObserver When Appropriate
For measuring elements, consider ResizeObserver as an alternative to layout effects:
function ResizeObserverExample() {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
// ResizeObserver is more efficient than layout effects for size changes
const resizeObserver = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
setDimensions({ width, height });
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, []);
return (
<div ref={elementRef}>
Content that might resize: {dimensions.width}x{dimensions.height}
</div>
);
}Common Anti-Patterns to Avoid
Using useLayoutEffect for Non-DOM Side Effects
// ❌ Bad - doesn't need to block paint
useLayoutEffect(() => {
// API calls don't need synchronous timing
fetchUserData(userId).then(setUser);
}, [userId]);
// ❌ Bad - local storage doesn't need to block paint
useLayoutEffect(() => {
localStorage.setItem('theme', currentTheme);
}, [currentTheme]);
// ✅ Good - use regular useEffect instead
useEffect(() => {
fetchUserData(userId).then(setUser);
}, [userId]);Overusing for “Feels Faster”
function OverEngineeredComponent() {
const [data, setData] = useState(null);
// ❌ Bad - no visual benefit, just blocking paint
useLayoutEffect(() => {
// This doesn't prevent visual flicker, just delays everything
processData(data);
}, [data]);
// ✅ Better - let the user see the update immediately
useEffect(() => {
processData(data);
}, [data]);
}React 19 Considerations
React 19’s Concurrent Features make useLayoutEffect even more important to use judiciously. The new rendering model makes blocking synchronous work more visible to users.
function React19Aware() {
const [items, setItems] = useState<Item[]>([]);
// Consider if you can use startTransition to make updates non-blocking
const updateItems = useCallback((newItems: Item[]) => {
startTransition(() => {
setItems(newItems);
});
}, []);
// Only use layout effect if you absolutely need sync DOM access
useLayoutEffect(() => {
// Measure only what you must measure synchronously
const criticalMeasurements = measureCriticalElements();
applyCriticalUpdates(criticalMeasurements);
}, [items]);
// Move non-critical work to regular effects
useEffect(() => {
updateNonCriticalUI(items);
}, [items]);
}Testing Layout Effect Performance
Create performance benchmarks to validate your useLayoutEffect usage:
function PerformanceTest() {
const [iterations, setIterations] = useState(0);
const timeRef = useRef<number[]>([]);
useLayoutEffect(() => {
const start = performance.now();
// Simulate your actual layout work
for (let i = 0; i < 100; i++) {
const el = document.createElement('div');
document.body.appendChild(el);
const rect = el.getBoundingClientRect(); // Force layout
document.body.removeChild(el);
}
const duration = performance.now() - start;
timeRef.current.push(duration);
if (timeRef.current.length > 10) {
const avg = timeRef.current.reduce((a, b) => a + b) / timeRef.current.length;
console.log(`Average layout effect time: ${avg.toFixed(2)}ms`);
timeRef.current = [];
}
}, [iterations]);
return (
<button onClick={() => setIterations((i) => i + 1)}>
Run Performance Test (iterations: {iterations})
</button>
);
}