React rendert. State ändert sich, die Komponente läuft neu, das DOM aktualisiert sich. Dieser Prozess ist normalerweise schnell genug, dass der Nutzer nichts merkt. Aber manchmal ist er es nicht. Manchmal löst eine State-Änderung eine teure Berechnung aus – eine große Liste neu rendern, komplexe Filter anwenden, ein umfangreiches Dashboard aktualisieren. Der Browser blockiert. Eingaben werden verzögert. Die UI fühlt sich träge an.
Das ist kein Bug, sondern eine Konsequenz von JavaScript’s Single-Threaded-Natur. Solange React rechnet, kann der Browser nichts anderes tun. Kein Input verarbeiten, kein Scrollen, keine Animationen. Die UI friert ein.
React 18 führte Concurrent Rendering ein – die Fähigkeit,
Rendering-Arbeit zu unterbrechen, zu pausieren und zu priorisieren.
useTransition ist das Interface zu diesem System. Er
erlaubt es, Updates als “nicht-dringend” zu markieren. React kann diese
Updates dann intelligent verzögern, unterbrechen oder abbrechen, wenn
wichtigere Arbeit ansteht.
Das Ergebnis: Die UI bleibt responsive, selbst während teurer Berechnungen im Hintergrund laufen.
Betrachten wir eine Suchfunktion mit Live-Filterung über eine große Produktliste.
function ProductSearch() {
const [query, setQuery] = useState('');
const [filteredProducts, setFilteredProducts] = useState(allProducts);
const handleSearch = (value: string) => {
setQuery(value);
// Teure Operation: 10.000 Produkte filtern
const filtered = allProducts.filter(product =>
product.name.toLowerCase().includes(value.toLowerCase()) ||
product.description.toLowerCase().includes(value.toLowerCase()) ||
product.tags.some(tag => tag.toLowerCase().includes(value.toLowerCase()))
);
setFilteredProducts(filtered);
};
return (
<div>
<input
value={query}
onChange={e => handleSearch(e.target.value)}
/>
<ProductList products={filteredProducts} />
</div>
);
}
Der Nutzer tippt “laptop”. Bei jedem Buchstaben wird die gesamte Produktliste neu gefiltert. 10.000 Produkte durchsuchen, String-Operationen durchführen, Array filtern. Bei jedem Tastendruck.
Das Ergebnis: Input Lag. Der Nutzer tippt “l”, “a”, “p”, “t”, “o”, “p”, aber die Buchstaben erscheinen verzögert. Das Input-Feld reagiert träge, weil React mit dem Filtern beschäftigt ist. Die UI fühlt sich kaputt an.
Das Problem liegt nicht nur in der Filterung selbst, sondern in der
Priorisierung. React behandelt beide State-Updates –
setQuery und setFilteredProducts – gleich.
Beide sind “dringend”. React blockiert, bis beide abgeschlossen
sind.
Aber sind sie wirklich gleich dringend? Das Input-Feld muss sofort reagieren – der Nutzer tippt und erwartet unmittelbares Feedback. Die gefilterte Produktliste kann warten. Eine Verzögerung von 100ms ist für den Nutzer kaum merkbar.
useTransition ermöglicht es, Updates in zwei Kategorien
einzuteilen:
Dringende Updates reagieren auf direkte Nutzerinteraktion. Input-Felder müssen sofort aktualisieren, Buttons müssen sofort visuelles Feedback geben, Hover-Effekte müssen unmittelbar sichtbar sein. Diese Updates haben höchste Priorität.
Nicht-dringende Updates sind Folgeoperationen, die verzögert werden können. Suchergebnisse, gefilterte Listen, Dashboard-Updates, Navigation zu neuen Routen. Diese Updates können warten, unterbrochen werden oder sogar verworfen werden, wenn ein neuerer nicht-dringender Update kommt.
Die API ist einfach:
const [isPending, startTransition] = useTransition();
startTransition ist eine Funktion, die einen Callback
nimmt. Alle State-Updates innerhalb dieses Callbacks werden als
nicht-dringend markiert. isPending ist ein Boolean, der
true ist, während ein Transition läuft.
Die Produktsuche, überarbeitet:
function ProductSearch() {
const [query, setQuery] = useState('');
const [filteredProducts, setFilteredProducts] = useState(allProducts);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
// Dringend: Input muss sofort reagieren
setQuery(value);
// Nicht-dringend: Filter kann warten
startTransition(() => {
const filtered = allProducts.filter(product =>
product.name.toLowerCase().includes(value.toLowerCase()) ||
product.description.toLowerCase().includes(value.toLowerCase()) ||
product.tags.some(tag => tag.toLowerCase().includes(value.toLowerCase()))
);
setFilteredProducts(filtered);
});
};
return (
<div>
<input
value={query}
onChange={e => handleSearch(e.target.value)}
/>
{isPending && <Spinner />}
<ProductList products={filteredProducts} />
</div>
);
}
Jetzt passiert folgendes: Der Nutzer tippt “l”.
setQuery("l") läuft sofort. Das Input-Feld aktualisiert
sich ohne Verzögerung. Gleichzeitig startet die Filterung im
Hintergrund, aber sie blockiert nicht. Wenn der Nutzer “a” tippt, bevor
die Filterung von “l” abgeschlossen ist, wird die “l”-Filterung
unterbrochen, setQuery("a") läuft sofort, und eine neue
Filterung für “la” startet.
Das Input-Feld bleibt responsive. Die Produktliste aktualisiert sich im Hintergrund, ohne die Eingabe zu blockieren.
useTransition gibt zwei Werte zurück:
const [isPending, startTransition] = useTransition();
startTransition(callback) markiert alle
State-Updates innerhalb von callback als nicht-dringend.
Diese Updates werden von React mit niedriger Priorität behandelt und
können unterbrochen werden.
startTransition(() => {
setExpensiveState(newValue); // Nicht-dringend
setAnotherState(anotherValue); // Auch nicht-dringend
});
Wichtig: Nur State-Updates innerhalb des Callbacks sind betroffen. Andere Updates bleiben dringend.
setImmediateState(value); // Dringend
startTransition(() => {
setDeferredState(value); // Nicht-dringend
});
setAnotherImmediateState(value); // Dringend
isPending ist true, solange eine
Transition läuft. Das ist nützlich für UI-Feedback – Spinner zeigen,
Buttons deaktivieren, visuelle Hinweise geben.
if (isPending) {
return <Spinner />;
}
// Oder subtiler:
<div className={isPending ? 'opacity-50' : ''}>
<ProductList products={filteredProducts} />
</div>
Ein wichtiges Detail: isPending wird erst
true, nachdem React die dringenden Updates verarbeitet hat.
Das verhindert Flackern bei sehr schnellen Transitions.
// User tippt, Transition startet
// React: Verarbeite dringende Updates (Input-Feld) sofort
// React: isPending = true
// React: Starte Transition im Hintergrund
// Transition abgeschlossen: isPending = false
Der klassische Fall. Nutzer tippen, Ergebnisse filtern sich live.
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
const handleSearch = (value: string) => {
setQuery(value);
startTransition(() => {
const lowerQuery = value.toLowerCase();
const filtered = items.filter(item =>
item.title.toLowerCase().includes(lowerQuery) ||
item.description.toLowerCase().includes(lowerQuery)
);
setFilteredItems(filtered);
});
};
return (
<div>
<input
value={query}
onChange={e => handleSearch(e.target.value)}
placeholder="Suche..."
/>
{isPending && <div className="searching-indicator">Suche...</div>}
<ul>
{filteredItems.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
</div>
);
}
Verschiedene Tabs, jeder mit komplexem Inhalt. Der Tab-Wechsel soll sofort visuell passieren, auch wenn der neue Inhalt Zeit braucht.
function Dashboard() {
const [activeTab, setActiveTab] = useState<'overview' | 'analytics' | 'reports'>('overview');
const [isPending, startTransition] = useTransition();
const switchTab = (tab: typeof activeTab) => {
startTransition(() => {
setActiveTab(tab);
});
};
return (
<div>
<nav>
<button
onClick={() => switchTab('overview')}
disabled={activeTab === 'overview'}
>
Übersicht
</button>
<button
onClick={() => switchTab('analytics')}
disabled={activeTab === 'analytics'}
>
Analytics {isPending && activeTab !== 'analytics' && <Spinner />}
</button>
<button
onClick={() => switchTab('reports')}
disabled={activeTab === 'reports'}
>
Reports
</button>
</nav>
<div className={isPending ? 'opacity-50' : ''}>
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'analytics' && <AnalyticsTab />}
{activeTab === 'reports' && <ReportsTab />}
</div>
</div>
);
}
Der Tab-Button reagiert sofort. Der Inhalt lädt im Hintergrund.
isPending zeigt visuelles Feedback.
Nutzer ändert Sortierreihenfolge. Die Liste soll neu sortiert werden, aber das UI soll responsive bleiben.
function SortableTable({ data }: { data: Row[] }) {
const [sortBy, setSortBy] = useState<'name' | 'date' | 'price'>('name');
const [sortedData, setSortedData] = useState(data);
const [isPending, startTransition] = useTransition();
const handleSort = (column: typeof sortBy) => {
setSortBy(column);
startTransition(() => {
const sorted = [...data].sort((a, b) => {
if (column === 'name') return a.name.localeCompare(b.name);
if (column === 'date') return a.date.getTime() - b.date.getTime();
if (column === 'price') return a.price - b.price;
return 0;
});
setSortedData(sorted);
});
};
return (
<div>
<div>
<button onClick={() => handleSort('name')}>Nach Name</button>
<button onClick={() => handleSort('date')}>Nach Datum</button>
<button onClick={() => handleSort('price')}>Nach Preis</button>
{isPending && <Spinner />}
</div>
<table>
<tbody>
{sortedData.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.date.toLocaleDateString()}</td>
<td>${row.price}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Navigation zu einer neuen Route, die viele Daten laden muss.
function App() {
const [currentPage, setCurrentPage] = useState<'home' | 'products' | 'about'>('home');
const [isPending, startTransition] = useTransition();
const navigate = (page: typeof currentPage) => {
startTransition(() => {
setCurrentPage(page);
});
};
return (
<div>
<nav>
<a onClick={() => navigate('home')}>Home</a>
<a onClick={() => navigate('products')}>Products</a>
<a onClick={() => navigate('about')}>About</a>
{isPending && <ProgressBar />}
</nav>
{currentPage === 'home' && <HomePage />}
{currentPage === 'products' && <ProductsPage />}
{currentPage === 'about' && <AboutPage />}
</div>
);
}
Die Navigation ist sofort sichtbar (URL ändert sich, aktiver Nav-Link aktualisiert sich), aber der Seiteninhalt lädt im Hintergrund.
Wenn ein Transition startet, passiert folgendes:
Das ist das “Concurrent” in Concurrent Rendering. React kann Arbeit pausieren, fortsetzen, verwerfen. Der Render-Prozess ist nicht mehr monolithisch und blockierend, sondern flexibel und unterbrechbar.
useTransition ist kein Allheilmittel. Es gibt Szenarien,
wo es nicht hilft oder sogar schadet.
Nicht für alle langsamen Updates
Wenn ein Update langsam ist, weil die Berechnung ineffizient ist,
macht useTransition es nicht schneller. Es verzögert nur
das Rendering. Die Arbeit bleibt gleich.
// ❌ useTransition hilft nicht
startTransition(() => {
// Ineffizienter Algorithmus O(n³)
const result = expensiveButInefficient(data);
setState(result);
});
// ✓ Optimieren Sie zuerst
const result = efficientAlgorithm(data); // O(n log n)
startTransition(() => {
setState(result);
});
Nicht für API-Calls
useTransition ist für State-Updates gedacht, nicht für
async Operations.
// ❌ Falsch
startTransition(() => {
fetch('/api/data') // Async, passiert außerhalb von React
.then(data => setState(data));
});
// ✓ Richtig
const handleFetch = async () => {
const data = await fetch('/api/data').then(res => res.json());
startTransition(() => {
setState(data); // Nur das State-Update ist Transition
});
};
Nicht für kritische Updates
Manche Updates müssen sofort sichtbar sein. Form-Validierung, Error-Meldungen, Warnungen.
// ❌ Falsch: Fehler muss sofort sichtbar sein
startTransition(() => {
setError('Passwort zu kurz!');
});
// ✓ Richtig: Fehler ist dringend
setError('Passwort zu kurz!');
Nicht als Ersatz für Optimierung
useTransition verbessert die wahrgenommene Performance,
nicht die tatsächliche. Wenn Ihre App langsam ist, optimieren Sie
zuerst.
| Problem | Lösung | useTransition hilft? |
|---|---|---|
| Ineffizienter Algorithmus | Algorithmus optimieren | ✗ Nein |
| Zu viele Re-Renderings | useMemo, React.memo | ✗ Nein |
| Große Daten-Listen | Virtualisierung | ✗ Nein |
| Langsames Rendering durch teure Berechnungen | Optimierung, dann Transition | ✓ Ja |
| Input Lag bei Suche/Filter | Transition | ✓ Ja |
useTransition arbeitet mit anderen Hooks zusammen, aber
es gibt Nuancen.
Mit useState:
const [urgent, setUrgent] = useState('');
const [deferred, setDeferred] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (value: string) => {
setUrgent(value); // Dringend
startTransition(() => {
setDeferred(value); // Nicht-dringend
});
};
Mit useEffect:
Effects, die auf Transition-State reagieren, laufen nach der Transition.
useEffect(() => {
// Läuft nach Transition-Completion
console.log('Filtered items:', filteredItems);
}, [filteredItems]);
Mit useMemo:
Teure Berechnungen sollten trotzdem memoiziert werden.
// ✓ Kombinieren Sie beide
const expensiveValue = useMemo(() =>
expensiveCalculation(data),
[data]
);
startTransition(() => {
setStateBasedOnValue(expensiveValue);
});
React DevTools Profiler zeigt Transitions an. Sie können sehen, welche Updates als Transitions markiert sind und wie lange sie dauern.
function MonitoredComponent() {
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (isPending) {
console.time('Transition');
} else {
console.timeEnd('Transition');
}
}, [isPending]);
// ...
}
Messen Sie die wahrgenommene Performance, nicht nur die Zahlen. Eine Transition, die 200ms dauert, aber die UI responsive hält, ist besser als ein blockierender Update von 100ms.
1. Minimaler isPending-Overhead
Zeigen Sie Loading-Indikatoren, aber halten Sie sie subtil. Die UI sollte nutzbar bleiben.
// ✓ Subtil
<div className={isPending ? 'opacity-70' : ''}>
{content}
</div>
// ❌ Zu aufdringlich
{isPending ? <FullScreenSpinner /> : content}
2. Optimieren Sie vor dem Transitionieren
// 1. Optimieren
const memoizedFilter = useMemo(() =>
items.filter(condition),
[items, condition]
);
// 2. Dann transitionieren
startTransition(() => {
setFiltered(memoizedFilter);
});
3. Granulare Transitions
Wrappen Sie nur die Updates, die wirklich verzögert werden können.
// ✓ Granular
setQuery(value); // Sofort
startTransition(() => {
setResults(filtered); // Verzögert
});
// ❌ Zu breit
startTransition(() => {
setQuery(value); // Sollte sofort sein!
setResults(filtered);
});
useTransition ist React’s Antwort auf das
Input-Lag-Problem. Er macht Apps nicht schneller, aber er macht sie
responsive. Die Kunst liegt darin, zu erkennen, welche Updates sofort
sein müssen und welche warten können. Richtig eingesetzt, ist
useTransition der Unterschied zwischen einer trägen und
einer flüssigen User Experience – nicht durch mehr Performance, sondern
durch intelligentere Priorisierung.