21 useLayoutEffect – Reaktion auf Layout-Änderungen vor dem Paint

React’s Rendering-Pipeline ist eine Abfolge klar definierter Phasen. Komponenten rendern, React aktualisiert das DOM, der Browser zeichnet die Änderungen auf dem Bildschirm. useEffect fügt sich am Ende dieser Pipeline ein – die UI ist bereits sichtbar, wenn der Effect läuft. Für die meisten Side Effects ist das perfekt. Aber manchmal ist es zu spät.

Stellen Sie sich vor, Sie müssen die Höhe eines Elements messen und basierend darauf eine andere Komponente positionieren. Mit useEffect sieht der Nutzer einen Flicker: erst die Standardposition, dann ein Sprung zur korrekten Position, sobald der Effect läuft und die Messung abgeschlossen ist. Diese visuelle Inkonsistenz ist genau das, was useLayoutEffect vermeidet.

useLayoutEffect ist die synchrone Variante von useEffect. Er läuft nach den DOM-Updates, aber vor dem Browser-Paint. Änderungen, die in einem useLayoutEffect vorgenommen werden, sind bereits im ersten sichtbaren Frame enthalten. Kein Flicker, kein Nachladen, kein visueller Sprung.

21.1 Das Timing verstehen: Eine kritische Millisekunde

Der Browser-Rendering-Prozess folgt einem strengen Ablauf. React rendert Komponenten, berechnet den neuen Virtual DOM, führt einen Diff durch und aktualisiert das echte DOM. Dann übernimmt der Browser: Layout-Berechnung, Paint, Composite. Das Ergebnis wird auf dem Bildschirm sichtbar.

useEffect wird nach diesem gesamten Prozess aufgerufen – asynchron, ohne das Rendering zu blockieren. useLayoutEffect hingegen wird zwischen DOM-Update und Browser-Paint aufgerufen – synchron, und blockiert den Paint, bis er abgeschlossen ist.

Diese Unterscheidung mag subtil erscheinen – eine Frage von Millisekunden. Aber für die visuelle Wahrnehmung ist sie entscheidend. Das menschliche Auge ist extrem sensibel für Bewegung und Positionsänderungen. Ein Element, das nach dem Paint springt, wird sofort bemerkt und als “ruckelig” oder “unfertig” wahrgenommen.

21.2 Wann useLayoutEffect unverzichtbar ist

Die klassischen Anwendungsfälle für useLayoutEffect drehen sich um DOM-Messungen und Layout-Korrekturen, die sofort sichtbar sein müssen.

DOM-Messungen sind der Hauptgrund. Wenn Sie die Dimensionen, Position oder andere geometrische Eigenschaften eines Elements benötigen, um darauf basierend Änderungen vorzunehmen, muss die Messung vor dem Paint erfolgen.

function Tooltip({ targetRef, content }: Props) {
  const tooltipRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ top: 0, left: 0 });
  
  useLayoutEffect(() => {
    if (!tooltipRef.current || !targetRef.current) return;
    
    const targetRect = targetRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();
    
    let top = targetRect.bottom + 8;
    let left = targetRect.left;
    
    // Kollision mit rechtem Bildschirmrand?
    if (left + tooltipRect.width > window.innerWidth) {
      left = window.innerWidth - tooltipRect.width - 8;
    }
    
    // Kollision mit unterem Bildschirmrand?
    if (top + tooltipRect.height > window.innerHeight) {
      top = targetRect.top - tooltipRect.height - 8;
    }
    
    setPosition({ top, left });
  }, [targetRef, content]);
  
  return (
    <div
      ref={tooltipRef}
      style={{
        position: 'fixed',
        top: position.top,
        left: position.left
      }}
    >
      {content}
    </div>
  );
}

Mit useEffect würde der Tooltip zunächst an Position (0, 0) erscheinen, dann zur korrekten Position springen. Mit useLayoutEffect wird die Position berechnet, bevor der Tooltip sichtbar wird. Der Nutzer sieht ihn nur an der korrekten Position.

Scroll-Positionen sind ein weiterer Fall. Wenn eine Komponente mountet und zu einem bestimmten Element scrollen soll, muss das vor dem Paint geschehen. Sonst sieht der Nutzer erst die Standard-Scroll-Position, dann den Sprung.

function MessageList({ messages, scrollToId }: Props) {
  const listRef = useRef<HTMLDivElement>(null);
  
  useLayoutEffect(() => {
    if (!scrollToId) return;
    
    const element = document.getElementById(scrollToId);
    if (element) {
      element.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }
  }, [scrollToId]);
  
  return (
    <div ref={listRef}>
      {messages.map(msg => (
        <div key={msg.id} id={msg.id}>{msg.text}</div>
      ))}
    </div>
  );
}

Animationen, die auf aktuellen DOM-Eigenschaften basieren, benötigen oft useLayoutEffect. Wenn eine Animation von der aktuellen Position eines Elements ausgeht, muss diese Position gemessen werden, bevor die Animation startet.

function AnimatedBox({ isOpen }: { isOpen: boolean }) {
  const boxRef = useRef<HTMLDivElement>(null);
  const [startPosition, setStartPosition] = useState({ x: 0, y: 0 });
  
  useLayoutEffect(() => {
    if (!isOpen || !boxRef.current) return;
    
    const rect = boxRef.current.getBoundingClientRect();
    setStartPosition({ x: rect.left, y: rect.top });
    
    // Animation starten mit bekannter Startposition
  }, [isOpen]);
  
  return <div ref={boxRef}>{/* Content */}</div>;
}

Third-Party-Bibliotheken, die direkt mit dem DOM arbeiten – Chart-Libraries, Virtualization-Libraries, Grid-Systeme – benötigen oft korrekte Layout-Informationen vor dem ersten Paint. useLayoutEffect garantiert, dass die Library mit akkuraten Daten arbeitet.

function Chart({ data }: Props) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  
  useLayoutEffect(() => {
    if (!canvasRef.current) return;
    
    const chart = new ExternalChartLib(canvasRef.current);
    chart.setData(data);
    chart.render();
    
    return () => chart.destroy();
  }, [data]);
  
  return <canvas ref={canvasRef} />;
}

21.3 Die Performance-Falle

Der synchrone Charakter von useLayoutEffect ist Fluch und Segen zugleich. Er verhindert visuelle Inkonsistenzen, aber er blockiert den Browser. Solange der Effect läuft, kann nichts gezeichnet werden. Die UI ist eingefroren.

Für schnelle Operationen – eine Messung, ein State-Update basierend auf Dimensionen – ist das kein Problem. Moderne Browser und React sind optimiert. Aber wenn der Effect länger dauert, wird es kritisch.

// Problematisch: Teure Berechnung in useLayoutEffect
function ExpensiveLayout() {
  const [layout, setLayout] = useState([]);
  
  useLayoutEffect(() => {
    // Simuliert komplexe Layout-Berechnung
    const result = [];
    for (let i = 0; i < 10000; i++) {
      result.push(expensiveCalculation(i));
    }
    setLayout(result);  // Blockiert Paint für die gesamte Berechnung!
  }, []);
  
  return <div>{/* Rendering */}</div>;
}

Diese Komponente blockiert den Browser für die Dauer der Schleife. Bei 10.000 Iterationen können das Hunderte von Millisekunden sein. Die UI friert ein. Der Nutzer sieht nichts, kann nichts tun.

Die Lösung liegt entweder in der Optimierung – die Berechnung schneller machen – oder im Wechsel zu useEffect, wenn die visuelle Konsistenz das Blockieren nicht rechtfertigt.

Kriterium useEffect useLayoutEffect
Timing Nach Paint Vor Paint
Blockiert UI Nein Ja
Anwendung Side Effects ohne visuelle Implikation DOM-Messungen, Layout-Korrekturen
Performance-Risiko Niedrig Hoch bei langen Operations
Visuelles Flicker Möglich Verhindert

21.4 Wann useEffect ausreicht

Die Versuchung ist groß, useLayoutEffect zu verwenden, um “sicherzugehen”. Aber in den meisten Fällen ist useEffect die bessere Wahl. Die asynchrone Ausführung belasst dem Browser mehr Kontrolle und führt zu flüssigeren Interfaces.

Daten-Fetching gehört nie in useLayoutEffect. API-Aufrufe sind asynchron und haben nichts mit Layout zu tun. useEffect ist hier korrekt.

// Richtig: useEffect für async Operations
function DataComponent() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(setData);
  }, []);
  
  return <div>{data ? data.title : 'Lädt...'}</div>;
}

Logging, Analytics, Tracking haben keine visuellen Implikationen. useEffect ist ausreichend und verhindert, dass diese Operationen das Rendering blockieren.

// Richtig: useEffect für Tracking
function TrackedComponent({ id }: Props) {
  useEffect(() => {
    analytics.track('Component viewed', { id });
  }, [id]);
  
  return <div>Content</div>;
}

localStorage-Operationen können synchron sein, aber sie haben keine visuellen Effekte, die sofort sichtbar sein müssen. useEffect ist die passendere Wahl.

// Richtig: useEffect für localStorage
function PersistedState() {
  const [value, setValue] = useState(() => 
    localStorage.getItem('key') || 'default'
  );
  
  useEffect(() => {
    localStorage.setItem('key', value);
  }, [value]);
  
  return <input value={value} onChange={e => setValue(e.target.value)} />;
}

Die Faustregel: Wenn der Effect keine DOM-Messung durchführt und keine Layout-Änderung vornimmt, die sofort beim ersten Paint sichtbar sein muss, verwende useEffect.

21.5 Server-Side Rendering: Die Inkompatibilität

useLayoutEffect ist strikt Browser-only. Er setzt voraus, dass ein DOM existiert. In Server-Side-Rendering-Umgebungen – Next.js, Remix, jede SSR-Lösung – gibt es kein DOM. Der Effect kann nicht laufen.

React warnt in Development, wenn useLayoutEffect in SSR verwendet wird:

Warning: useLayoutEffect does nothing on the server, because its effect 
cannot be encoded into the server renderer's output format. This will 
lead to a mismatch between the initial, non-hydrated UI and the intended UI.

Die Lösung hängt vom Kontext ab. Wenn der Effect nur clientseitig relevant ist, kann man ihn conditional ausführen:

function ClientOnlyLayoutEffect({ children }: Props) {
  const [isMounted, setIsMounted] = useState(false);
  
  useEffect(() => {
    setIsMounted(true);
  }, []);
  
  useLayoutEffect(() => {
    if (!isMounted) return;
    
    // Layout-bezogener Code
  }, [isMounted]);
  
  return <>{children}</>;
}

Oder man erstellt einen Custom Hook, der serverseitig zu useEffect degradiert:

const useIsomorphicLayoutEffect = 
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

// Verwendung
function Component() {
  useIsomorphicLayoutEffect(() => {
    // Läuft als useLayoutEffect im Browser
    // Läuft als useEffect auf dem Server (no-op)
  }, []);
}

Dieser Hook vermeidet die Warnung und funktioniert in beiden Umgebungen – mit den entsprechenden Timing-Unterschieden.

21.6 Praktische Patterns

Ein häufiges Pattern ist die Kombination aus useLayoutEffect für initiale Messung und useEffect für Updates:

function AdaptiveComponent() {
  const ref = useRef<HTMLDivElement>(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  
  // Initiale Messung vor erstem Paint
  useLayoutEffect(() => {
    if (!ref.current) return;
    
    const rect = ref.current.getBoundingClientRect();
    setDimensions({ width: rect.width, height: rect.height });
  }, []);
  
  // Weitere Messungen bei Resize
  useEffect(() => {
    if (!ref.current) return;
    
    const observer = new ResizeObserver(entries => {
      const rect = entries[0].contentRect;
      setDimensions({ width: rect.width, height: rect.height });
    });
    
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);
  
  return (
    <div ref={ref}>
      Breite: {dimensions.width}, Höhe: {dimensions.height}
    </div>
  );
}

Die initiale Messung muss vor dem Paint erfolgen (kein Flicker beim Mount). Spätere Messungen bei Resize können async sein (useEffect), weil der Nutzer das Resizing sowieso sieht.

Für Scroll-Restoration nach Navigation:

function ScrollRestoredList({ items }: Props) {
  const listRef = useRef<HTMLDivElement>(null);
  const previousScrollPos = useRef(0);
  
  useLayoutEffect(() => {
    // Scroll-Position wiederherstellen vor Paint
    if (listRef.current) {
      listRef.current.scrollTop = previousScrollPos.current;
    }
  });
  
  useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    
    // Scroll-Position speichern
    const handleScroll = () => {
      previousScrollPos.current = list.scrollTop;
    };
    
    list.addEventListener('scroll', handleScroll);
    return () => list.removeEventListener('scroll', handleScroll);
  }, []);
  
  return (
    <div ref={listRef}>
      {items.map(item => <div key={item.id}>{item.text}</div>)}
    </div>
  );
}

Die Restoration ist synchron (useLayoutEffect), damit der Nutzer die Liste sofort an der korrekten Position sieht. Das Tracking ist asynchron (useEffect), weil es keine visuelle Implikation hat.

21.7 Debugging: Das Timing visualisieren

Um zu verstehen, wann welcher Effect läuft, können Console-Logs helfen:

function TimingVisualization() {
  console.log('1. Render');
  
  useLayoutEffect(() => {
    console.log('2. useLayoutEffect (vor Paint)');
  });
  
  useEffect(() => {
    console.log('3. useEffect (nach Paint)');
  });
  
  return <div>Timing Test</div>;
}

// Console Output:
// 1. Render
// 2. useLayoutEffect (vor Paint)
// [Browser zeichnet jetzt]
// 3. useEffect (nach Paint)

Diese Reihenfolge ist garantiert. useLayoutEffect läuft immer vor useEffect, weil er vor dem Paint ausgeführt wird, und useEffect erst danach.

React DevTools Profiler zeigt auch die Timing-Informationen. Layout-Effects, die lange dauern, werden als Performance-Bottlenecks markiert.

21.8 Die 16ms-Regel

Um 60 Frames pro Sekunde zu erreichen, hat jeder Frame 16.67ms Budget. Alles, was länger dauert, führt zu Frame-Drops und sichtbarem Ruckeln.

useLayoutEffect zählt zu diesem Budget. Wenn er 10ms benötigt, bleiben nur 6ms für den Rest – Rendering, Layout-Berechnung, Paint. Das ist knapp. Deshalb sollten Layout-Effects extrem schnell sein – idealerweise unter 5ms.

// Problematisch: Könnte >16ms dauern
useLayoutEffect(() => {
  const elements = document.querySelectorAll('.item');
  elements.forEach(el => {
    const rect = el.getBoundingClientRect();
    performExpensiveCalculation(rect);
  });
}, []);

// Besser: Nur wenn nötig, optimiert
useLayoutEffect(() => {
  const element = elementRef.current;
  if (!element) return;
  
  const rect = element.getBoundingClientRect();
  setPosition(calculatePosition(rect));  // Schnelle Berechnung
}, []);

Wenn eine Operation länger dauert, sollte sie entweder optimiert oder in useEffect verschoben werden, mit Akzeptanz des möglichen Flickers. Die Alternative – ein eingefrorenes Interface – ist oft schlimmer als ein kurzes Flackern.

useLayoutEffect ist ein Spezialwerkzeug für spezielle Probleme. Wenn visuelle Konsistenz vor dem ersten Paint kritisch ist und die Operation schnell genug ist, ist er unverzichtbar. Für alles andere bleibt useEffect die richtige Wahl – asynchron, nicht-blockierend und ausreichend für die überwiegende Mehrheit der Side Effects.