Blog

React Transitions: nicht dringende Updates mit useTransition

useTransition markiert Updates als nicht dringend und hält React während teurer oder suspendierender Renderings responsiv. Dazu useDeferredValue als Werkzeug für nachgelagerte Renderings.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (11 Min.)

MP3 herunterladen

Zwei Prioritäten braucht eine React-Oberfläche: eine für das, was sofort passieren muss, und eine für das, was warten darf. Reacts Scheduler kennt unterschiedliche Prioritäten, doch ein gewöhnliches State-Update wird zunächst als direkte Aktualisierung behandelt. Erst mit einer Transition markiert die Anwendung die nachfolgende Renderarbeit ausdrücklich als nicht blockierend.

Am deutlichsten wird das an einer Stelle, um die es im Beitrag zu Suspense und dem use-Hook schon ging: dem harten Zurückspringen in den Fallback. Der Bereich zeigt gerade noch echten Inhalt, der State ändert sich, die Daten sind weg – und der Spinner erscheint. Genau so kam in einem Projekt die Rückfrage eines Kollegen: „Der Content steckt doch in einer Suspense-Grenze. Wenn sich der State ändert, greift der Fallback. Was sollen wir dagegen machen?“ Man sagt React, dass dieses eine Update nicht dringend ist. Dafür gibt es useTransition.

Dringend oder nicht dringend – die einzige Unterscheidung, die zählt

Die Trennlinie verläuft zwischen zwei Sorten von Updates. Auf der einen Seite die dringenden: Ich tippe einen Buchstaben, das Zeichen muss sofort im Eingabefeld stehen. Ich hake eine Checkbox an, sie muss sofort angehakt sein. Alles, was sich wie eine direkte physische Reaktion anfühlen soll, ist dringend. Verzögert React das auch nur um 100 Millisekunden, fühlt sich die App kaputt an.

Auf der anderen Seite die nicht-dringenden Updates: Ich wechsle den Tab, ich ändere den Filter einer großen Liste, ich stelle die Suchanfrage um. Das Ergebnis dieser Aktion darf einen Moment brauchen. Niemand erwartet, dass 10.000 neu gefilterte Zeilen in null Millisekunden erscheinen. Was aber jeder erwartet: dass die Oberfläche in der Zwischenzeit nicht einfriert und nicht sinnlos flackert.

Welche fachliche Reaktion warten darf, kann React nicht aus dem Namen eines State-Werts ableiten. Mit den Concurrent Features, die seit React 18 und dem Wechsel auf createRoot stabil verfügbar sind, bekommen wir die Werkzeuge, um diese Absicht mitzuteilen. useTransition ist das wichtigste davon.

Die Signatur ist bewusst schlicht

import { useTransition } from 'react';

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('overview');

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <TabButton active={tab === 'overview'} onClick={() => selectTab('overview')}>
        Overview
      </TabButton>
      <TabButton active={tab === 'reports'} onClick={() => selectTab('reports')}>
        Reports
      </TabButton>
      <section style={{ opacity: isPending ? 0.6 : 1 }}>
        <TabPanel tab={tab} />
      </section>
    </>
  );
}

Der Hook nimmt keine Parameter und gibt ein Zwei-Tupel zurück: isPending, ein Boolean, und startTransition, eine Funktion. Aufrufen darf man ihn wie jeden Hook nur in einer Komponente oder einem Custom Hook.

Der entscheidende Teil steckt in selectTab. Der setTab-Aufruf wandert in den Callback von startTransition. Damit sagt die Komponente React: „Dieses Update ist eine Transition.“ Wenn das neue Panel dabei innerhalb einer bereits sichtbaren Suspense-Grenze suspendiert, kann React den bisherigen Inhalt stehen lassen, statt ihn sofort durch den Fallback zu ersetzen. Neu hinzukommende Suspense-Grenzen dürfen ihren Fallback dagegen weiterhin zeigen. Solange der Übergang läuft, ist isPending auf true. Genau daran hängt in meinem Beispiel die dezente Ausgrauung über opacity.

Das ist die konkrete Einlösung des Suspense-Versprechens: Ein Update innerhalb von startTransition verhindert, dass bereits sichtbarer Content in den Fallback zurückspringt.

Was im Hintergrund wirklich passiert

„React macht das schon“ reicht als Erklärung nicht, deshalb der Ablauf explizit als Diagramm. Wichtig ist die Verzweigung am Ende: Eine Transition ist unterbrechbar.

flowchart TD
    A[Nutzer klickt Tab] --> B[setTab läuft in<br/>startTransition]
    B --> C[React rendert neuen Tab<br/>im Hintergrund]
    C --> D[Alte UI bleibt sichtbar<br/>isPending = true]
    D --> E{Neue Daten /<br/>Render fertig?}
    E -->|ja| F[Wechsel zur neuen UI<br/>isPending = false]
    E -->|dringendes Update<br/>kommt rein| G[Hintergrund-Render wird<br/>verworfen und neu gestartet]
    G --> C

Der linke Pfad ist der Normalfall: React rendert im Hintergrund, hält die alte Oberfläche, zeigt isPending und wechselt am Ende. Der rechte Pfad ist der Grund, warum die App bei teuren Updates überhaupt responsiv bleibt. Kommt während des Hintergrund-Renderings ein dringenderes Update herein – etwa eine Tastatureingabe – dann verwirft React die halbfertige Arbeit und beginnt neu. Man muss nicht warten, bis das teure Rendering durch ist, um wieder tippen zu können. Diese Unterbrechbarkeit ist der eigentliche Trick hinter dem Wort „concurrent“.

startTransition ohne Komponente

Nicht jeder State-Wechsel entsteht in einer Komponente. In Routern und Stores sitzt die auslösende Logik oft außerhalb des Komponentenbaums. Für diesen Fall gibt es den Standalone-Import:

import { startTransition } from 'react';

function navigate(url) {
  startTransition(() => {
    setCurrentUrl(url);
  });
}

Diese Variante funktioniert überall, hat aber einen Preis: Sie liefert kein isPending. Wer den Ladezustand anzeigen will, braucht den Hook. Wer nur ein Update als nicht-dringend markieren will und den Pending-State an anderer Stelle beschafft, ist mit dem Standalone-Import gut bedient. Ich verwende ihn bewusst in Router-Callbacks, wo die Navigationsanzeige ohnehin woanders sitzt.

Seit React 19, stabil seit Dezember 2024, akzeptiert startTransition übrigens auch async-Funktionen; darauf bauen die neuen Actions und <form action> intern auf. Das ist ein eigenes Thema – hier zählt nur: Der Transition-Mechanismus selbst ist die Grundlage darunter.

Der Klassiker unter den Fehlern: das Eingabefeld einfrieren

In Code-Reviews taucht an dieser Stelle immer wieder derselbe naheliegende Fehler auf. Wenn eine Transition teure Renderings entschärft, warum wickelt man dann nicht einfach jedes Update ein – auch das des Suchfelds?

// Do NOT do this
function onChange(e) {
  startTransition(() => {
    setText(e.target.value);
  });
}

Das Ergebnis ist ein träges, schwammiges Eingabefeld. Der Grund: Text im Input ist ein dringendes Update. Es fühlt sich direkt physisch an, jeder Buchstabe muss sofort stehen. Packt man es in eine Transition, darf React es verzögern und unterbrechen – und tut das auch. Controlled Inputs bleiben also dringend, der Text-State wird ganz normal gesetzt. Was teuer ist, ist nicht das Tippen, sondern das, was aus dem Text folgt – die gefilterte Liste. Und für die gibt es ein eigenes Werkzeug.

useDeferredValue: wenn ein Teil der Anzeige nachlaufen darf

useTransition markiert State-Updates an ihrer auslösenden Stelle. useDeferredValue setzt anders an: Der aktuelle Wert bleibt dringend und sichtbar, während ein teurer Verbraucher vorübergehend mit der vorherigen Version weiterarbeiten darf. Das ist bei Props nützlich, aber ebenso bei lokalem Input-State, dessen abgeleitete Liste langsamer nachlaufen soll.

import { useDeferredValue, useState } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <SlowList query={deferredQuery} />
      </div>
    </>
  );
}

Hier bleibt das Eingabefeld dringend: setQuery läuft ohne jede Transition, jeder Buchstabe erscheint sofort. Die teure SlowList bekommt aber nicht query, sondern deferredQuery. React rendert zuerst mit dem alten Wert weiter – die Liste bleibt kurz stehen – und rendert dann im Hintergrund mit dem neuen Wert nach. Über den Vergleich query !== deferredQuery weiß ich, ob die Liste gerade veraltet ist, und graue sie in der Zwischenzeit dezent aus.

Die Signatur nimmt seit React 19 ein optionales zweites Argument: useDeferredValue(value, initialValue). Ohne initialValue wird beim allerersten Render nicht deferred; gibt man es an, rendert der erste Durchlauf mit diesem Startwert und schiebt den echten Wert in den Hintergrund. Für den Alltag reicht die einargumentige Form fast immer.

Ein Punkt, der immer wieder zu Missverständnissen führt: useDeferredValue ist kein Debounce und kein Throttle. Es gibt keinen festen Delay, und es verhindert keine Netzwerk-Requests. Es ist reine Render-Priorisierung. Wer bei jedem Tastendruck einen Fetch auslöst, hat mit useDeferredValue denselben Fetch-Sturm wie vorher – nur das Rendering der Ergebnisse ist entzerrt. Debouncing des Requests bleibt eine getrennte Aufgabe.

Der zweite Klassiker: der verlorene Kontext nach await

Sobald async-Funktionen ins Spiel kommen, tappt fast jeder in dieselbe Falle:

// The transition context is lost after the await
startTransition(async () => {
  const data = await fetchPage(url);
  setPage(data); // no longer part of the transition
});

Das setPage nach dem await läuft nicht mehr innerhalb der Transition. React verfolgt den Transition-Kontext synchron; nach einem await ist der ursprüngliche Aufruf längst zurückgekehrt. Der Fix ist unspektakulär: das Update nach dem await erneut einwickeln.

startTransition(async () => {
  const data = await fetchPage(url);
  startTransition(() => {
    setPage(data);
  });
});

Verwandt dazu ein weiterer Stolperstein: Ein set-Aufruf, der aus einem setTimeout heraus passiert, wird ebenfalls nicht als Transition markiert, selbst wenn der setTimeout innerhalb von startTransition steht. Der Callback läuft erst später, außerhalb des synchronen Fensters. startTransition erwartet ein synchrones Update; alles, was in einen späteren Tick verschoben wird, fällt aus dem Transition-Kontext heraus.

Wann welches Werkzeug – der Entscheidungsbaum

Ob useTransition oder useDeferredValue greift, hängt daran, was verzögert werden darf: der State-Übergang selbst oder nur ein nachgelagerter Teil der Anzeige.

flowchart TD
    A[Teures Update soll<br/>Interaktion nicht blockieren] --> B{Darf der Übergang<br/>selbst warten?}
    B -->|ja| C[useTransition /<br/>startTransition]
    B -->|nein, nur Verbraucher| D[useDeferredValue]
    C --> E[Update wird markiert<br/>isPending verfügbar]
    D --> F[Aktueller Wert bleibt direkt<br/>Verbraucher läuft nach]
    E --> G[Beide: alte UI bleibt stehen<br/>statt Fallback]
    F --> G

Die Trennlinie ist also nicht „welches ist moderner“, sondern schlicht die Frage der Kontrolle. Zur Verdichtung die beiden Werkzeuge nebeneinander:

  • useTransition / startTransition: Ich steuere ein Update, dessen sichtbares Ergebnis warten darf. Ich will das harte Zurückspringen in den Fallback vermeiden und brauche mit dem Hook zusätzlich isPending. Typisch: Tab-Wechsel oder eine nicht dringende Navigation.
  • useDeferredValue: Der aktuelle Wert muss sofort gelten, aber ein teurer Verbraucher darf noch kurz die vorige Version sehen. Ein Pending-Flag gibt es nicht, wohl aber den Vergleich value !== deferredValue, um veraltete Anzeigen auszugrauen. Typisch: die große abgeleitete Liste unter einem schnellen Eingabefeld.

Gemeinsam ist beiden, dass weniger dringende Renderarbeit hinter einer direkten Interaktion zurückstehen darf. Bei einer bereits sichtbaren Suspense-Grenze kann dadurch der vorhandene Inhalt stehen bleiben. Der Unterschied liegt darin, ob das auslösende Update oder ein Wert für nachgelagerte Verbraucher verzögert wird; isPending liefert nur useTransition.

Was ich in der Praxis empfehle

Ein paar Leitplanken, die sich in Projekten bewährt haben:

  • State, der ein kontrolliertes Textfeld steuert, bleibt dringend und darf nicht in eine Transition. Bei anderen direkten Eingaben prüfe ich, welcher Teil sofort reagieren muss und welche Folgearbeit warten darf.
  • Navigations- und Tab-Wechsel können in startTransition gehören, wenn ihr Ziel suspendiert oder teuer rendert. Router können diese Koordination bereits selbst übernehmen.
  • Für teure abgeleitete Renderings unter einem schnellen Input greift useDeferredValue, weil der aktuelle Eingabewert sofort sichtbar bleiben soll und nur die Liste nachläuft.
  • isPending und value !== deferredValue zeigen an, dass etwas gerade veraltet ist. Ich nutze sie für dezente Ausgrauung statt für einen ganzseitigen Spinner. Der Sinn der Übung ist ja, dass der Inhalt eben nicht verschwindet.
  • Der Kontextverlust nach await und in setTimeout ist tückisch. Wenn ein Update partout dringend wirkt, obwohl es in einer Transition steht, ist das fast immer die Ursache.

Wer die Concurrent Features grundsätzlich einordnen möchte, findet den größeren Zusammenhang in meinem React-Überblick von JSX zur Architektur. Der springende Punkt für heute bleibt eng: React kann nicht wissen, welche fachliche Reaktion sofort sichtbar sein muss und welche Renderarbeit warten darf. useTransition und useDeferredValue drücken diese Absicht an unterschiedlichen Stellen aus. Bei teuren Renderings kann die Oberfläche dadurch responsiv bleiben; bei bereits sichtbarem Suspense-Inhalt lässt sich ein unnötiges Zurückspringen in den Fallback vermeiden.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare