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.
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.
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} />;
}
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 |
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.
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.
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.
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.
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.