Skip to content

useLayoutEffect vs useEffect

intermediate11 min read

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.
  });
}
Mental Model

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;
Common Trap

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

FeatureuseEffectuseLayoutEffect
TimingAfter browser paintBefore browser paint
BlockingNon-blockingBlocks paint
DOM visibleUser has seen DOMUser has not seen DOM yet
Use caseData fetching, subscriptions, loggingDOM measurement, preventing flicker
SSRWorks (no-op if no DOM)Warning on server
PerformanceBetter — does not block paintCan hurt — blocks paint
Default choiceYes — use this unless you see flickerNo — 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.

Execution Trace
Render
React calls component function
Virtual DOM tree created
Commit
DOM mutations applied
Real DOM updated but browser has NOT painted
useLayoutEffect
Synchronous: measure DOM, update styles
Runs before paint — user sees nothing yet
Paint
Browser renders pixels to screen
User sees the final result — no intermediate flicker
useEffect
Asynchronous: fetch data, subscribe, log
Runs after paint — does not block visual update
What developers doWhat 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
Quiz
What is the timing difference between useEffect and useLayoutEffect?
Quiz
Why does useLayoutEffect cause problems during server-side rendering?
Quiz
When should you choose useLayoutEffect over useEffect?
Key Rules
  1. 1useEffect runs after paint — default choice for most effects
  2. 2useLayoutEffect runs before paint — use only for DOM measurement and preventing flicker
  3. 3useLayoutEffect blocks paint — keep it fast, no network requests or heavy computation
  4. 4useLayoutEffect does not run on the server — use useIsomorphicLayoutEffect for SSR
  5. 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.