The useLayoutEffect hook runs synchronously after DOM mutations but before the browser paints. Here's when and how to use it.
Basic Usage#
1import { useLayoutEffect, useRef, useState } from 'react';
2
3function MeasuredComponent() {
4 const ref = useRef<HTMLDivElement>(null);
5 const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
6
7 // Runs synchronously after DOM update
8 useLayoutEffect(() => {
9 if (ref.current) {
10 const { width, height } = ref.current.getBoundingClientRect();
11 setDimensions({ width, height });
12 }
13 }, []);
14
15 return (
16 <div ref={ref}>
17 <p>Width: {dimensions.width}px</p>
18 <p>Height: {dimensions.height}px</p>
19 </div>
20 );
21}vs useEffect#
1import { useEffect, useLayoutEffect, useState } from 'react';
2
3function FlickerExample() {
4 const [position, setPosition] = useState(0);
5
6 // ❌ useEffect - may cause visual flicker
7 useEffect(() => {
8 // DOM is painted before this runs
9 // User might see initial position briefly
10 setPosition(calculatePosition());
11 }, []);
12
13 // ✅ useLayoutEffect - no flicker
14 useLayoutEffect(() => {
15 // Runs before browser paints
16 // User never sees initial position
17 setPosition(calculatePosition());
18 }, []);
19
20 return <div style={{ transform: `translateX(${position}px)` }} />;
21}
22
23// Timeline comparison:
24// useEffect: Render → Paint → Effect → (possible re-paint)
25// useLayoutEffect: Render → Layout Effect → PaintTooltip Positioning#
1function Tooltip({ target, children }) {
2 const tooltipRef = useRef<HTMLDivElement>(null);
3 const [position, setPosition] = useState({ top: 0, left: 0 });
4
5 useLayoutEffect(() => {
6 if (!target || !tooltipRef.current) return;
7
8 const targetRect = target.getBoundingClientRect();
9 const tooltipRect = tooltipRef.current.getBoundingClientRect();
10
11 // Calculate position to avoid cutoff
12 let top = targetRect.bottom + 8;
13 let left = targetRect.left + (targetRect.width - tooltipRect.width) / 2;
14
15 // Adjust if off-screen
16 if (left < 0) left = 8;
17 if (left + tooltipRect.width > window.innerWidth) {
18 left = window.innerWidth - tooltipRect.width - 8;
19 }
20
21 setPosition({ top, left });
22 }, [target]);
23
24 return (
25 <div
26 ref={tooltipRef}
27 style={{
28 position: 'fixed',
29 top: position.top,
30 left: position.left,
31 }}
32 >
33 {children}
34 </div>
35 );
36}Auto-resize Textarea#
1function AutoResizeTextarea({ value, onChange }) {
2 const textareaRef = useRef<HTMLTextAreaElement>(null);
3
4 useLayoutEffect(() => {
5 const textarea = textareaRef.current;
6 if (!textarea) return;
7
8 // Reset height to calculate scroll height
9 textarea.style.height = 'auto';
10 // Set to scroll height
11 textarea.style.height = `${textarea.scrollHeight}px`;
12 }, [value]);
13
14 return (
15 <textarea
16 ref={textareaRef}
17 value={value}
18 onChange={onChange}
19 style={{
20 resize: 'none',
21 overflow: 'hidden',
22 minHeight: '100px',
23 }}
24 />
25 );
26}Scroll Position Restoration#
1function ChatMessages({ messages }) {
2 const containerRef = useRef<HTMLDivElement>(null);
3 const [shouldScrollToBottom, setShouldScrollToBottom] = useState(true);
4
5 // Check if user is near bottom before update
6 const handleScroll = () => {
7 const container = containerRef.current;
8 if (!container) return;
9
10 const { scrollTop, scrollHeight, clientHeight } = container;
11 setShouldScrollToBottom(scrollHeight - scrollTop - clientHeight < 100);
12 };
13
14 // Scroll to bottom after new messages
15 useLayoutEffect(() => {
16 if (shouldScrollToBottom && containerRef.current) {
17 containerRef.current.scrollTop = containerRef.current.scrollHeight;
18 }
19 }, [messages, shouldScrollToBottom]);
20
21 return (
22 <div
23 ref={containerRef}
24 onScroll={handleScroll}
25 style={{ height: '400px', overflow: 'auto' }}
26 >
27 {messages.map((msg) => (
28 <div key={msg.id}>{msg.text}</div>
29 ))}
30 </div>
31 );
32}Focus Management#
1function Modal({ isOpen, onClose, children }) {
2 const modalRef = useRef<HTMLDivElement>(null);
3 const previousFocus = useRef<HTMLElement | null>(null);
4
5 useLayoutEffect(() => {
6 if (isOpen) {
7 // Store current focus
8 previousFocus.current = document.activeElement as HTMLElement;
9
10 // Focus modal immediately
11 modalRef.current?.focus();
12 }
13
14 return () => {
15 // Restore focus when closing
16 previousFocus.current?.focus();
17 };
18 }, [isOpen]);
19
20 if (!isOpen) return null;
21
22 return (
23 <div
24 ref={modalRef}
25 tabIndex={-1}
26 role="dialog"
27 aria-modal="true"
28 >
29 {children}
30 <button onClick={onClose}>Close</button>
31 </div>
32 );
33}Animation Setup#
1function AnimatedList({ items }) {
2 const itemRefs = useRef<Map<string, HTMLLIElement>>(new Map());
3
4 useLayoutEffect(() => {
5 // Measure all items before animation
6 const positions = new Map();
7
8 itemRefs.current.forEach((el, id) => {
9 positions.set(id, el.getBoundingClientRect());
10 });
11
12 // Apply FLIP animation
13 itemRefs.current.forEach((el, id) => {
14 const oldPos = positions.get(id);
15 const newPos = el.getBoundingClientRect();
16
17 const deltaY = oldPos.top - newPos.top;
18
19 if (deltaY !== 0) {
20 el.animate(
21 [
22 { transform: `translateY(${deltaY}px)` },
23 { transform: 'translateY(0)' },
24 ],
25 { duration: 300, easing: 'ease-out' }
26 );
27 }
28 });
29 }, [items]);
30
31 return (
32 <ul>
33 {items.map((item) => (
34 <li
35 key={item.id}
36 ref={(el) => {
37 if (el) itemRefs.current.set(item.id, el);
38 }}
39 >
40 {item.name}
41 </li>
42 ))}
43 </ul>
44 );
45}Dynamic Width Measurement#
1function TruncatedText({ text, maxWidth }) {
2 const ref = useRef<HTMLSpanElement>(null);
3 const [truncatedText, setTruncatedText] = useState(text);
4
5 useLayoutEffect(() => {
6 const element = ref.current;
7 if (!element) return;
8
9 let current = text;
10 element.textContent = current;
11
12 while (element.scrollWidth > maxWidth && current.length > 0) {
13 current = current.slice(0, -1);
14 element.textContent = current + '...';
15 }
16
17 setTruncatedText(element.textContent || '');
18 }, [text, maxWidth]);
19
20 return (
21 <span ref={ref} style={{ display: 'inline-block', maxWidth }}>
22 {truncatedText}
23 </span>
24 );
25}Third-Party Library Integration#
1function ChartComponent({ data }) {
2 const containerRef = useRef<HTMLDivElement>(null);
3 const chartRef = useRef<Chart | null>(null);
4
5 useLayoutEffect(() => {
6 if (!containerRef.current) return;
7
8 // Initialize chart synchronously
9 chartRef.current = new Chart(containerRef.current, {
10 type: 'line',
11 data: data,
12 });
13
14 return () => {
15 chartRef.current?.destroy();
16 };
17 }, []);
18
19 // Update data synchronously
20 useLayoutEffect(() => {
21 if (chartRef.current) {
22 chartRef.current.data = data;
23 chartRef.current.update('none'); // No animation
24 }
25 }, [data]);
26
27 return <div ref={containerRef} />;
28}Window Resize Handler#
1function ResponsiveGrid({ children }) {
2 const containerRef = useRef<HTMLDivElement>(null);
3 const [columns, setColumns] = useState(3);
4
5 useLayoutEffect(() => {
6 const calculateColumns = () => {
7 if (!containerRef.current) return;
8
9 const width = containerRef.current.offsetWidth;
10
11 if (width < 480) setColumns(1);
12 else if (width < 768) setColumns(2);
13 else if (width < 1024) setColumns(3);
14 else setColumns(4);
15 };
16
17 calculateColumns();
18
19 window.addEventListener('resize', calculateColumns);
20 return () => window.removeEventListener('resize', calculateColumns);
21 }, []);
22
23 return (
24 <div
25 ref={containerRef}
26 style={{
27 display: 'grid',
28 gridTemplateColumns: `repeat(${columns}, 1fr)`,
29 gap: '1rem',
30 }}
31 >
32 {children}
33 </div>
34 );
35}SSR Considerations#
1import { useLayoutEffect, useEffect } from 'react';
2
3// Create isomorphic effect
4const useIsomorphicLayoutEffect =
5 typeof window !== 'undefined' ? useLayoutEffect : useEffect;
6
7function Component() {
8 // Safe for SSR
9 useIsomorphicLayoutEffect(() => {
10 // DOM operations
11 }, []);
12
13 return <div />;
14}
15
16// Or suppress SSR warning
17function ClientOnlyComponent() {
18 useLayoutEffect(() => {
19 // Only runs on client
20 }, []);
21
22 return <div suppressHydrationWarning />;
23}Best Practices#
When to Use:
✓ DOM measurements
✓ Preventing visual flicker
✓ Synchronous DOM mutations
✓ Focus management
vs useEffect:
✓ useLayoutEffect: visual updates
✓ useEffect: data fetching, subscriptions
✓ useLayoutEffect: blocks paint
✓ useEffect: non-blocking
Performance:
✓ Keep effects fast
✓ Avoid heavy computation
✓ Consider throttling resize
✓ Use useEffect when possible
Avoid:
✗ Data fetching
✗ Subscriptions (use useEffect)
✗ Long-running operations
✗ SSR without isomorphic check
Conclusion#
The useLayoutEffect hook runs synchronously after DOM mutations but before paint, making it ideal for measurements and visual updates that must happen before the user sees the result. Use it sparingly for cases where useEffect would cause visual flicker. For most side effects, prefer useEffect to avoid blocking the browser.