22 useTransition – UI-Updates priorisieren

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.

22.1 Das Problem: Alles ist gleich dringend

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.

22.2 Die Lösung: Priorisierung durch Transitions

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.

22.3 Die Mechanik: isPending und startTransition

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

22.4 Praktische Anwendungsfälle

22.4.1 1. Live-Suche und Filterung

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>
  );
}

22.4.2 2. Tab-Navigation mit schweren Inhalten

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.

22.4.3 3. Sortierung großer Listen

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>
  );
}

22.4.4 4. Router-Navigation

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.

22.5 Was passiert unter der Haube?

Wenn ein Transition startet, passiert folgendes:

  1. React verarbeitet alle dringenden Updates sofort
  2. React beginnt mit dem Transition-Update
  3. Wenn ein neuer dringender Update kommt, wird der Transition unterbrochen
  4. Der dringende Update wird sofort verarbeitet
  5. Der Transition wird fortgesetzt oder neu gestartet

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.

22.6 Wann NICHT verwenden

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

22.7 Integration mit anderen Hooks

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);
});

22.8 Performance-Monitoring

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.

22.9 Best Practices

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.