richtig einsetzen in React 18
Teure State-Updates blockieren den Haupt-Thread und frieren Eingaben ein. useTransition markiert Updates als niedrig priorisiert, sodass React sie unterbrechbar rendert – Eingaben bleiben reaktiv, und isPending signalisiert den Ladezustand ohne useState-Overhead.
Inhaltsverzeichnis
- 1. Was das Concurrent-Mode-Problem mit teurem State-Update bedeutet
- 2. Das Grundprinzip von useTransition
- 3. isPending: Ladezustand ohne useState
- 4. Klassischer Anwendungsfall: Tab-Navigation
- 5. Sucheingaben reaktiv halten
- 6. useTransition mit Suspense und Server Components
- 7. useTransition vs. useDeferredValue
- 8. Vergleich: blockierendes vs. nicht-blockierendes Update
- 9. Grenzen und häufige Fehler
- 10. Zusammenfassung
- 11. FAQ
1. Was das Concurrent-Mode-Problem mit teurem State-Update bedeutet
Jedes State-Update in React triggert einen Render. Wenn dieser Render teuer ist – weil viele Komponenten neu gerendert werden, weil eine große Liste gefiltert wird oder weil das Rendering-Ergebnis aufwendige Berechnungen erfordert – blockiert React den Haupt-Thread für die Dauer des Renders. Während dieser Zeit reagieren Eingabefelder verzögert, Buttons wirken eingefroren, Scroll-Events werden nicht verarbeitet. Für den Nutzer fühlt sich die Anwendung träge an.
Das Problem verschärft sich bei interaktiven Szenarien: Eine Sucheingabe, die bei jedem Tastendruck eine große Liste filtert. Ein Tab-Wechsel, der eine aufwendige Datenansicht aufbaut. Ein Filterpanel, das hunderte von Produkten neu sortiert. In allen diesen Fällen konkurriert das teure State-Update mit den Eingabe-Events um den Haupt-Thread. useTransition löst dieses Problem, indem es React ermöglicht, das teure Update zu unterbrechen, wenn dringende Updates – wie Tastatureingaben – einlaufen. Das ist der Kern des Concurrent-Mode-Versprechens: priorisiertes Rendering statt First-Come-First-Served.
2. Das Grundprinzip von useTransition
useTransition gibt ein Tupel zurück: [isPending, startTransition]. Die Funktion startTransition nimmt einen Callback entgegen. State-Updates innerhalb dieses Callbacks werden als Transition markiert – als niedrig priorisierte Updates, die React unterbrechen und später fertigstellen darf. React beginnt den Render sofort, aber wenn ein höher priorisiertes Update einläuft (ein Tastatureingabe-Event, ein Klick), unterbricht React den Transition-Render, verarbeitet das dringende Update synchron und setzt den Transition-Render danach fort.
Wichtig zu verstehen: Der aktuelle angezeigte State bleibt sichtbar, solange die Transition läuft. React zeigt nicht den unvollständigen Zwischen-State – die bisherige UI bleibt bestehen. Nur isPending wird auf true gesetzt, um den laufenden Übergang zu signalisieren. Wenn die Transition abgeschlossen ist, wechselt React atomar zum neuen State. Das unterscheidet sich fundamental von einem direkten setState, das sofort einen teilweise gerenderten State sichtbar machen würde.
// useTransition basic pattern — non-blocking tab switch
import { useState, useTransition } from 'react';
import { HeavyTabContent } from './HeavyTabContent'; // expensive render
const TABS = ['Produkte', 'Statistiken', 'Berichte'] as const;
type Tab = typeof TABS[number];
export function TabPanel() {
const [activeTab, setActiveTab] = useState<Tab>('Produkte');
const [isPending, startTransition] = useTransition();
const handleTabClick = (tab: Tab) => {
// Wrap in startTransition — marks this as low-priority
startTransition(() => {
setActiveTab(tab); // React renders HeavyTabContent in background
});
};
return (
<div>
<nav>
{TABS.map(tab => (
<button
key={tab}
onClick={() => handleTabClick(tab)}
style={ {
fontWeight: activeTab === tab ? 'bold' : 'normal',
opacity: isPending ? 0.7 : 1, // visual feedback during transition
} }
>
{tab}
</button>
))}
</nav>
{/* Old content stays visible until transition completes */}
<div style={ { opacity: isPending ? 0.5 : 1 } }>
<HeavyTabContent tab={activeTab} />
</div>
{isPending && <span>Wird geladen...</span>}
</div>
);
}
3. isPending: Ladezustand ohne useState
isPending ist das zweite Element des useTransition-Tupels und ersetzt in vielen Fällen einen separaten loading-State. Es wird automatisch auf true gesetzt, sobald eine Transition gestartet wird, und kehrt zu false zurück, wenn der Transition-Render abgeschlossen und committed wurde. Das bedeutet: Kein manuelles setLoading(true) vor dem Update, kein setLoading(false) danach, kein Risiko, dass der Loading-State bei einem Fehler hängenbleibt.
isPending eignet sich hervorragend für subtile visuelle Rückmeldungen: reduzierte Opacity des Inhalts, ein Spinner in der Navigationsleiste, ein deaktivierter Zustand für den aktiven Tab-Button. Für aggressivere Ladebilder – einen vollständigen Loading-Screen, der die bisherige UI komplett ersetzt – ist isPending weniger geeignet, weil die bisherige UI per Design sichtbar bleibt. In diesem Fall ist Suspense mit einem Fallback die bessere Wahl.
4. Klassischer Anwendungsfall: Tab-Navigation
Tab-Navigation ist der Paradefall für useTransition. Ohne Transition blockiert ein teurer Tab-Content-Render den Thread, bis der neue Inhalt vollständig gerendert ist. Mehrfachklicks auf Tabs in dieser Zeit werden eingereiht, was zu einer ruckartigen Serie von State-Wechseln führt. Mit useTransition kann React jeden neuen Tab-Klick als dringend behandeln und den laufenden Tab-Render abbrechen, um direkt zum zuletzt angeklickten Tab zu springen – ohne die Zwischenstadien zu rendern. Für den Nutzer bedeutet das: Der zuletzt geklickte Tab wird angezeigt, ohne Verzögerung durch alle vorherigen Klicks.
Ein weiteres Detail, das Tab-Navigation mit useTransition verbessert: Da die alte UI sichtbar bleibt, während die neue geladen wird, gibt es kein Layout-Shift-Problem. Klassische Implementierungen zeigen beim Tab-Wechsel oft einen kurzen Moment eine leere Fläche, bevor der neue Content erscheint. Mit useTransition ist der alte Content bis zur vollständigen Bereitschaft des neuen sichtbar – ein deutlich sauberer visueller Übergang.
5. Sucheingaben reaktiv halten
Sucheingaben mit useTransition folgen dem Muster "dringend: Eingabe aktualisieren, nicht-dringend: Ergebnisse filtern". Die Eingabe des Nutzers in das Suchfeld ist hochpriorisiert – jede Verzögerung hier ist sofort wahrnehmbar. Das Filtern der Ergebnisliste ist eine teure Operation, die niedrig priorisiert werden kann, ohne dass der Nutzer es als störend wahrnimmt. Die Implementierung trennt die beiden State-Variablen: Der Eingabe-State wird direkt (synchron) gesetzt, der Such-State für das Filtering wird in startTransition gewickelt.
Die Alternative – useDeferredValue – funktioniert ähnlich, ist aber konzeptionell verschieden: useTransition markiert den Update-Aufruf als niedrig priorisiert, useDeferredValue liefert dem Empfänger eine verzögerte Kopie eines Wertes. Für Sucheingaben ist useTransition geeigneter, wenn die Filter-Operation selbst in einer eigenen setState liegt. useDeferredValue ist besser, wenn man eine externe Prop oder einen Wert, dessen Update-Aufruf man nicht kontrolliert, nach unten verlangsamen möchte.
// Reactive search input with useTransition — input stays fast
import { useState, useTransition, useMemo } from 'react';
interface Product { id: number; name: string; category: string; }
export function ProductSearch({ products }: { products: Product[] }) {
const [inputValue, setInputValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInputValue(value); // urgent: input updates immediately
startTransition(() => {
setSearchQuery(value); // non-urgent: filter list in transition
});
};
// Expensive filter — runs in transition context
const filtered = useMemo(
() => products.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.category.toLowerCase().includes(searchQuery.toLowerCase())
),
[products, searchQuery]
);
return (
<div>
<input
value={inputValue}
onChange={handleChange}
placeholder="Produkte suchen..."
/>
{isPending && <span>Suche läuft...</span>}
<ul style={ { opacity: isPending ? 0.6 : 1 } }>
{filtered.map(p => (
<li key={p.id}>{p.name} — {p.category}</li>
))}
</ul>
<p>{filtered.length} Ergebnisse</p>
</div>
);
}
6. useTransition mit Suspense und Server Components
In React 18 arbeiten useTransition und Suspense eng zusammen. Wenn eine Komponente innerhalb einer Transition-Boundary eine Datei nachladen muss – etwa durch React.lazy() oder durch eine Suspense-Grenze, die auf Daten wartet – bleibt der Übergang in der Pending-Phase, bis die Komponente bereit ist. Der Suspense-Fallback wird dabei nicht angezeigt, solange der Übergang noch läuft und bereits eine Boundary vorhanden ist – stattdessen bleibt die bisherige UI sichtbar. Erst wenn die Transition-Zeit überschritten wird, zeigt React den Suspense-Fallback.
Mit Next.js 15 und React Server Components wird useTransition noch relevanter: Server-Action-Aufrufe laufen in Transitionen, Router-Navigationen können als Transitions markiert werden, und das Nachladen von Server-Component-Daten – das intern auf Suspense basiert – bleibt für den Nutzer nahtlos. startTransition ist auch als standalone Import von React verfügbar, ohne den isPending-State – für Fälle, in denen der Ladezustand nicht angezeigt werden muss.
7. useTransition vs. useDeferredValue
useTransition und useDeferredValue lösen dasselbe Problem – teure Updates niedrig zu priorisieren – aus unterschiedlichen Perspektiven. useTransition arbeitet auf der Quelle des Updates: Man wickelt den setState-Aufruf in startTransition und markiert damit den State-Wechsel selbst als niedrig priorisiert. useDeferredValue arbeitet auf dem Empfänger: Man gibt ihm einen Wert, und er liefert eine verzögerte Kopie, die erst dann aktualisiert wird, wenn der Browser Zeit hat.
useTransition ist die richtige Wahl, wenn man den Update-Aufruf selbst kontrolliert – zum Beispiel in einem eigenen Event-Handler. useDeferredValue ist die richtige Wahl, wenn man einen Wert empfängt, dessen Quelle man nicht kontrolliert – etwa eine Prop oder ein Context-Wert, der sich schnell ändert und dessen Verwendung in einer teuren Komponente man bremsen möchte. Beide Hooks können kombiniert werden, sind aber selten beide gleichzeitig notwendig.
8. Vergleich: blockierendes vs. nicht-blockierendes Update
Der messbare Unterschied zwischen einem blockierenden und einem nicht-blockierenden State-Update zeigt sich am deutlichsten in der Input-Latenz: Die Zeit zwischen Tastendruck und sichtbarem Zeichen im Eingabefeld. Bei einem blockierenden Filter-Update, der 200 ms dauert, kann die Eingabe um bis zu 200 ms verzögert wirken. Mit useTransition wird die Tastatureingabe immer sofort verarbeitet – der Filter-Render darf hingegen unterbrochen werden.
| Eigenschaft | Direkte setState | startTransition(setState) |
|---|---|---|
| Eingabe-Reaktivität | Blockiert für Render-Dauer | Immer sofort reaktiv |
| Sichtbarer Zwischen-State | Ja (kurze leere Fläche) | Nein (alte UI bleibt) |
| Rapid Clicking | Alle States werden gerendert | Nur letzter State |
| Ladezustand | Manuell via useState | Automatisch via isPending |
| Suspense-Integration | Zeigt Fallback sofort | Hält alte UI bis bereit |
9. Grenzen und häufige Fehler
Der häufigste Fehler bei useTransition ist, Nicht-State-Updates in startTransition zu wickeln. startTransition erwartet einen synchronen Callback, der State-Update-Aufrufe enthält. Wer einen asynchronen Callback oder einen side-effectful Aufruf (DOM-Mutation, fetch) in startTransition schreibt, erzeugt undefiniertes Verhalten. In React 19 wird startTransition erweitert, um async Callbacks zu unterstützen – aber nur für das neue Server-Action-Pattern, nicht als generisches Async-Gateway.
Ein zweiter häufiger Fehler: Controlled Inputs. Wenn der Wert eines Inputs direkt an einen Transition-State gebunden ist, entsteht eine spürbare Eingabeverzögerung, weil der Input-Value erst nach der Transition aktualisiert wird. Die korrekte Lösung ist das bereits beschriebene doppelte State-Pattern: Eingabe-State synchron, Such-State in der Transition. useTransition ist auch kein Ersatz für Memoization mit useMemo und React.memo – Transitionen helfen bei Scheduling, nicht dabei, teure Berechnungen zu überspringen, wenn die Inputs unverändert sind.
// Common mistake: async in startTransition (React 18)
// WRONG — async not supported in React 18 startTransition
const [isPending, startTransition] = useTransition();
startTransition(async () => {
const data = await fetchData(); // breaks transition semantics
setData(data);
});
// RIGHT — keep transition sync, handle async separately
startTransition(() => {
// Only synchronous setState calls here
setIsLoading(true);
setFilter(newFilter); // sync update
});
// Fetch outside transition, update state inside
async function handleFilterChange(filter: string) {
startTransition(() => setFilter(filter)); // sync UI update
// Async fetch after transition for data loading
const result = await fetchFilteredData(filter);
startTransition(() => {
setData(result); // another transition for the data update
setIsLoading(false);
});
}
// WRONG: controlled input bound to transition state
const [query, setQuery] = useState('');
const [isPending2, startTransition2] = useTransition();
<input value={query} onChange={e => startTransition2(() => setQuery(e.target.value))} />
// Input lags because value is tied to transition state
// RIGHT: separate input value from query state
const [inputValue, setInputValue] = useState('');
const [queryState, setQueryState] = useState('');
<input value={inputValue} onChange={e => {
setInputValue(e.target.value); // sync: input stays reactive
startTransition(() => setQueryState(e.target.value)); // deferred: filter
} } />
10. Zusammenfassung
useTransition ist das Werkzeug, um teure State-Updates aus dem dringenden Render-Pfad herauszunehmen. Das Grundmuster ist einfach: Jeden State-Update, der eine teure Render-Kaskade auslöst, in startTransition wickeln. Die UI bleibt reaktiv, weil React dringende Updates – Tastatureingaben, Klicks – immer vorziehen darf. isPending liefert den Ladezustand kostenlos. Rapid-Clicking rendert nur den letzten State, weil React Transition-Renders verwirft, wenn ein neueres Update eintrifft.
Die wichtigsten Grenzen: Nur synchrone State-Updates in startTransition. Controlled Inputs nie an Transition-State binden. useTransition ist kein Ersatz für Memoization, sondern ein Scheduling-Tool. Für Werte, deren Quelle man nicht kontrolliert, ist useDeferredValue die Alternative. Für Sucheingaben, Tab-Navigation, Filter-Panels und jeden anderen Fall, bei dem ein teures Update auf Nutzereingaben folgt, ist useTransition die direkteste Lösung und sollte vor Debouncing und anderen Workarounds in Betracht gezogen werden.
useTransition — Das Wichtigste auf einen Blick
Non-Blocking Updates
startTransition markiert State-Updates als niedrig priorisiert – React darf sie unterbrechen, wenn dringende Updates einlaufen.
Kostenloser Ladezustand
isPending ist automatisch true während der Transition – kein manueller loading-State, kein Risiko, dass er hängenbleibt.
Alte UI bleibt sichtbar
Kein leerer Zwischen-State. React zeigt die bisherige UI, bis die Transition vollständig abgeschlossen ist – dann atomarer Wechsel.
Nur synchrone Updates
startTransition erwartet einen synchronen Callback. Keine async-Aufrufe, keine Side-Effects – nur setState-Aufrufe darin platzieren.