post

React Transitions: die alte Oberfläche stehen lassen mit useTransition

useTransition markiert ein Update als nicht-dringend, hält die bisherige Oberfläche sichtbar und wechselt erst, wenn die neuen Daten bereitstehen. Dazu useDeferredValue als verwandtes Werkzeug für teure Folge-Renderings.

In der letzten Schulung zu Suspense und dem use-Hook habe ich am Ende ein Versprechen gemacht, das mir jedes Mal jemand um die Ohren haut. Ich sagte sinngemäß: „Das harte Zurückspringen in den Fallback – dieses Flackern, bei dem der ganze Bereich verschwindet und der Spinner erscheint, obwohl ihr gerade eben noch echten Inhalt gesehen habt – das ist kein Naturgesetz. Das könnt ihr abstellen." Und dann kam, wie immer, die Rückfrage aus der zweiten Reihe: „Wie denn? Wir haben den Content doch in eine Suspense-Grenze gepackt. Wenn sich der State ändert, sind die Daten weg, also greift der Fallback. Was sollen wir dagegen machen?"

Die Antwort passt in einen Satz und braucht trotzdem einen halben Schulungstag, bis sie wirklich sitzt: Ihr sagt React, dass dieses eine Update nicht dringend ist. Und genau dafür gibt es useTransition.

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

Ich zeichne an dieser Stelle gern zwei Sorten von Updates an das Whiteboard. 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.

React kann diese beiden Sorten von Haus aus nicht auseinanderhalten. Jedes setState ist erst einmal gleich dringend. Mit den Concurrent Features, die seit React 18 und dem Wechsel auf createRoot stabil verfügbar sind, bekommen wir die Werkzeuge, um React die Priorität selbst 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 Daten laden muss und deshalb suspendet, dann wirf mir nicht den alten Inhalt weg. Lass das aktuelle Panel stehen und wechsle erst, wenn das neue fertig ist." Solange dieser Übergang läuft, ist isPending auf true. Genau daran hängt in meinem Beispiel die dezente Ausgrauung über opacity. Der Teilnehmer aus der zweiten Reihe sieht in dem Moment, dass sein Klick angekommen ist – aber er verliert den Inhalt nicht aus den Augen.

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

Ich lasse mir in Schulungen ungern mit „React macht das schon" durchgehen, deshalb hier 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

An dieser Stelle macht in fast jeder Schulung jemand den gleichen naheliegenden Fehler. 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. Die Regel, die ich den Teilnehmern mitgebe, lautet: Controlled Inputs bleiben 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 man den Setter nicht hat

useTransition setzt voraus, dass man das Update kontrolliert, dass man also Zugriff auf den set-Aufruf hat. Manchmal hat man den aber gar nicht. Der Wert kommt als Prop von oben herein, und die eigene Komponente kann nur reagieren, nicht auslösen. Für diesen Fall gibt es useDeferredValue.

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, den ich in Schulungen doppelt unterstreiche, weil er 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. Die Faustregel: 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

Nach zwei, drei Beispielen fragen Teilnehmer regelmäßig: „Woher weiß ich jetzt, ob ich useTransition oder useDeferredValue nehme?" Die Antwort hängt an genau einer Frage – ob ich den Setter in der Hand halte.

flowchart TD
    A[Teures Update soll<br/>Interaktion nicht blockieren] --> B{Habe ich Zugriff<br/>auf den State-Setter?}
    B -->|ja| C[useTransition /<br/>startTransition]
    B -->|nein, Wert kommt<br/>als Prop| D[useDeferredValue]
    C --> E[Ich steuere das Update<br/>und bekomme isPending]
    D --> F[Ich steuere nur den Wert<br/>kein Pending-Flag]
    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 das Update selbst, habe also den set-Aufruf in der Hand. Ich will das harte Zurückspringen in den Fallback vermeiden und brauche zusätzlich isPending für eine dezente Ladeanzeige. Typisch: Tab- und Filterwechsel, Navigation.
  • useDeferredValue: Ich steuere nur einen Wert, der als Prop hereinkommt, und habe keinen Setter. Ein teures Folge-Rendering soll die eigentliche Interaktion nicht blockieren. 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 das eigentliche Ziel: die bereits sichtbare Oberfläche stehen lassen, statt sie durch einen Fallback zu ersetzen. Der Unterschied liegt in der Setter-Kontrolle und im isPending-Flag.

Was ich in der Praxis empfehle

Ein paar Leitplanken, die sich bei mir in Projekten und Schulungen bewährt haben:

  • Direkte Eingaben – Text, Checkboxen, alles, was sich physisch anfühlen soll – bleiben dringend. Niemals in eine Transition wickeln.
  • Navigations- und Filterwechsel, deren Ziel Daten laden könnte, gehören in startTransition. Das ist der Ort, an dem sich das Fallback-Flackern abstellt.
  • Für teure abgeleitete Renderings unter einem schnellen Input greift useDeferredValue, nicht useTransition – schlicht weil der Setter des dringenden Werts nicht der ist, den man verzögern will.
  • isPending und value !== deferredValue sind eure Anzeiger für „gerade veraltet". Nutzt sie für dezente Ausgrauung statt für einen ganzseitigen Spinner. Der Sinn der Übung ist ja, dass der Inhalt eben nicht verschwindet.
  • Achtet auf den Kontextverlust nach await und in setTimeout. 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 von sich aus nicht wissen, welches eurer Updates wichtig ist und welches warten darf. useTransition und useDeferredValue sind die beiden Wege, ihm genau das zu sagen. Und sobald React es weiß, hält es die alte Oberfläche stehen, bis die neue wirklich bereit ist – kein Flackern, kein sinnloses Zurückspringen in den Fallback. Genau das Versprechen, das ich am Ende der Suspense-Schulung gegeben habe.

Weiterführende Quellen

Kommentare

Kommentar schreiben