Back to Blog
ReactHooksuseLayoutEffectDOM

React useLayoutEffect Hook Guide

Master the React useLayoutEffect hook for synchronous DOM measurements and visual updates.

B
Bootspring Team
Engineering
October 6, 2019
6 min read

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 → Paint

Tooltip 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.

Share this article

Help spread the word about Bootspring