Lazy Search ohne Debounce
Debounce mit setTimeout ist eine pragmatische Krücke, keine Lösung. useDeferredValue aus React 18 verschiebt teure Render-Arbeit ohne feste Zeitfenster – der Browser bleibt reaktionsfähig, während die Ergebnisliste im Hintergrund aktualisiert wird.
Inhaltsverzeichnis
- 1. Das Problem mit Debounce in React
- 2. Concurrent Mode: Wie React 18 Prioritäten vergibt
- 3. useDeferredValue: Syntax und Grundprinzip
- 4. Lazy-Search-Implementierung Schritt für Schritt
- 5. React.memo als notwendiger Partner
- 6. Stale-State-Feedback: Ladezustand anzeigen
- 7. useDeferredValue vs. Debounce im direkten Vergleich
- 8. Typische Fallstricke und wie man sie vermeidet
- 9. TypeScript-Integration und Typsicherheit
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit Debounce in React
Wer eine Echtzeit-Suche in React implementiert, greift reflexartig zu Debounce: Ein useEffect mit setTimeout verzögert den teuren Filter-Durchlauf um 300 Millisekunden, sobald der Nutzer aufhört zu tippen. Das funktioniert, hat aber fundamentale Schwächen. Ein festes Zeitfenster ist eine Heuristik – auf einem schnellen Desktop-Rechner sind 300 ms zu lang, auf einem günstigen Mobilgerät zu kurz. Das Ergebnis ist ein spürbares Flackern oder, schlimmer, eine träge UI, die auf Tastendrücke erst mit Verzögerung antwortet.
Das zweite Problem liegt in der React-Integration selbst. Ein Debounce-Timer lebt außerhalb des Render-Zyklus. Er wird in einem useEffect mit Cleanup-Funktion verwaltet, was zu subtilen Bugs führt: Wenn der Nutzer schnell tippt, können mehrere Timer gleichzeitig aktiv sein. Wird die Komponente unmounted, bevor der Timer feuert, und der Cleanup läuft nicht korrekt, werden State-Updates auf eine bereits unmounted Komponente angewendet – eine häufige Quelle von Memory-Leak-Warnungen. useDeferredValue löst genau dieses Problem durch native Browser-Concurrency statt durch willkürliche Timer.
Der dritte Aspekt betrifft die Nutzererfahrung unter Last. Wenn das JavaScript-Bundle groß ist und der Haupt-Thread beschäftigt ist, kann ein 300-ms-Debounce die Blockierung verlängern statt verkürzen – der Timer startet erst, wenn der Haupt-Thread wieder frei ist. Mit Concurrent Mode und useDeferredValue priorisiert React den Input-Handler und setzt teure Render-Arbeit zurück, sodass die UI unter Last reaktionsfähig bleibt.
2. Concurrent Mode: Wie React 18 Prioritäten vergibt
React 18 führt mit dem Concurrent Mode ein grundlegend neues Scheduling-Modell ein. Statt synchron von oben nach unten zu rendern, kann React 18 Render-Arbeit unterbrechen, zurückstellen und in kleineren Einheiten abarbeiten. Das Schlüsselkonzept dabei ist das Rendering als unterbrechbare Arbeit: Wenn ein dringenderer Update – etwa ein Tastendruck – eintrifft, während React gerade eine teure Liste rendert, kann React die laufende Arbeit pausieren, den dringenden Update verarbeiten und dann die Liste weitermalen.
Dieses Scheduling basiert auf Prioritätslanes. React unterscheidet zwischen synchronen Updates (Benutzereingaben wie Klicks und Tastendrücke) und Transitions (Updates, die das Ergebnis einer Eingabe darstellen, aber nicht sofort sichtbar sein müssen). useDeferredValue und startTransition sind die zwei APIs, mit denen Entwickler diesem Scheduler mitteilen können, welche Arbeit zurückgestellt werden darf. Der Unterschied: startTransition markiert eine State-Update-Funktion als niedrig priorisiert, während useDeferredValue einen bereits vorhandenen Wert als "darf veraltet sein" markiert.
Konkret bedeutet das für eine Suchkomponente: Der Input-State (query) wird immer synchron aktualisiert, damit der Nutzer sofort sieht, was er tippt. Der abgeleitete Wert deferredQuery, der für den teuren Filter-Durchlauf verwendet wird, hinkt nach – aber nicht um eine feste Zeit, sondern so lange, wie der Browser braucht, um dringende Arbeit abzuarbeiten. Auf schnellen Geräten ist die Verzögerung nahezu null, auf langsamen angemessen.
3. useDeferredValue: Syntax und Grundprinzip
useDeferredValue hat eine sehr einfache Schnittstelle: Der Hook nimmt einen Wert entgegen und gibt eine verzögerte Version dieses Werts zurück. In React 18 ist das zweite Argument noch nicht vorhanden; ab React 19 kann ein initialer Fallback-Wert übergeben werden. Das Kernprinzip: React rendert die Komponente zunächst mit dem alten Wert (dem "stale" Wert), um die UI sofort zu zeigen, und plant dann im Hintergrund ein Re-Render mit dem neuen Wert. Wenn während dieses Hintergrund-Renders ein weiterer Update eintrifft, wird der laufende Hintergrund-Render verworfen und neu gestartet.
Wichtig: useDeferredValue hilft nur, wenn die Komponente, die den Wert konsumiert, tatsächlich teuer zu rendern ist. Wenn das Rendering trivial ist, fügt der Hook nur unnötige Komplexität hinzu. Der Hook ist kein magischer Performance-Schalter – er ist ein Werkzeug, das teure Renders explizit als niedrig priorisiert markiert und React erlaubt, dringende Arbeit vorzuziehen. In Kombination mit React.memo wird verhindert, dass die Kind-Komponente bei jedem Keystroke neu gerendert wird, solange der deferred Wert noch gleich ist.
import { useState, useDeferredValue, memo, useMemo } from 'react';
// Expensive list component — only re-renders when deferredQuery changes
const SearchResults = memo(function SearchResults({
query,
items,
}: {
query: string;
items: string[];
}) {
// Simulate expensive filtering operation
const results = useMemo(() => {
if (!query) return items;
return items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
}, [query, items]);
return (
<ul>
{results.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
});
export function LazySearch({ items }: { items: string[] }) {
const [query, setQuery] = useState('');
// deferredQuery lags behind query — React prioritises the input update
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Suche …"
/>
{/* Visual feedback while deferred value catches up */}
<div style={ { opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' } }>
<SearchResults query={deferredQuery} items={items} />
</div>
</div>
);
}
4. Lazy-Search-Implementierung Schritt für Schritt
Eine vollständige Lazy-Search-Implementierung besteht aus drei Bausteinen: dem Input-State, dem deferred Wert und der memoiserten Ergebniskomponente. Der Input-State wird über useState verwaltet und ist der einzige State, der synchron aktualisiert wird – damit der Nutzer sofort visuelles Feedback erhält. Der deferred Wert wird von useDeferredValue erzeugt und hinkt nach. Die Ergebniskomponente ist mit React.memo umhüllt und bekommt ausschließlich den deferred Wert übergeben, nie den aktuellen Query-State.
In der Praxis verwaltet man häufig nicht nur einen Suchbegriff, sondern auch Filterparameter. Das Muster bleibt identisch: Alle Parameter, die einen teuren Filter-Durchlauf auslösen, werden in ein Objekt gebündelt, dieses Objekt wird an useDeferredValue übergeben, und die Ergebniskomponente bekommt nur den deferred Wert. React erkennt dabei, dass ein Objekt durch Referenz verglichen wird – eine neue Objektreferenz bei jedem Render löst immer ein Re-Render der Kind-Komponente aus. Deshalb muss das Filterobjekt mit useMemo stabilisiert werden, bevor es an useDeferredValue übergeben wird.
Ein oft übersehenes Detail: useDeferredValue ist nur effektiv, wenn die Kind-Komponente tatsächlich das Rendering überspringen kann, wenn der Wert sich nicht geändert hat. Das erfordert React.memo auf der Kind-Komponente. Ohne React.memo rendert React die Kind-Komponente bei jedem Render der Eltern-Komponente neu – und der deferred Wert hat keinen Effekt auf die Performance.
5. React.memo als notwendiger Partner
React.memo ist die Voraussetzung dafür, dass useDeferredValue seine Wirkung entfaltet. Der Hook selbst schiebt nur den Wert nach; ob das dazu führt, dass tatsächlich weniger gerendert wird, hängt davon ab, ob die Kind-Komponente das Rendering überspringt, solange ihre Props unverändert sind. Genau das leistet React.memo: Es vergleicht die alten und neuen Props flach und verhindert das Re-Render, wenn keine Prop sich geändert hat.
Der Standard-Vergleich von React.memo ist eine flache Gleichheitsprüfung mit Object.is. Das bedeutet, dass primitive Werte wie Strings und Numbers zuverlässig verglichen werden, Objekte und Arrays aber nur per Referenz. Wer ein komplexes Filter-Objekt als Prop übergibt, muss dafür sorgen, dass die Referenz stabil ist – durch useMemo in der Eltern-Komponente. Alternativ kann man React.memo eine benutzerdefinierte Vergleichsfunktion übergeben, die einen tiefen Vergleich durchführt – das ist aber nur selten nötig und kann selbst Overhead erzeugen.
import { useState, useDeferredValue, memo, useMemo } from 'react';
interface FilterParams {
query: string;
category: string;
minPrice: number;
}
interface Product {
id: number;
name: string;
category: string;
price: number;
}
// Wrapped with memo — only re-renders when filter reference changes
const ProductList = memo(
function ProductList({
filter,
products,
}: {
filter: FilterParams;
products: Product[];
}) {
const results = useMemo(() => {
return products.filter(
(p) =>
p.name.toLowerCase().includes(filter.query.toLowerCase()) &&
(filter.category === '' || p.category === filter.category) &&
p.price >= filter.minPrice
);
}, [filter, products]);
return (
<ul>
{results.map((p) => (
<li key={p.id}>
{p.name} — {p.category} — {p.price}€
</li>
))}
</ul>
);
}
);
export function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('');
const [minPrice, setMinPrice] = useState(0);
// Stable object reference — only changes when params change
const filter = useMemo(
() => ({ query, category, minPrice }),
[query, category, minPrice]
);
// Deferred filter — React may render with old filter first
const deferredFilter = useDeferredValue(filter);
const isStale = filter !== deferredFilter;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Produktname …" />
<input value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Kategorie …" />
<input type="number" value={minPrice} onChange={(e) => setMinPrice(Number(e.target.value))} />
<div style={ { opacity: isStale ? 0.6 : 1 } }>
<ProductList filter={deferredFilter} products={products} />
</div>
</div>
);
}
6. Stale-State-Feedback: Ladezustand anzeigen
Wenn useDeferredValue eingesetzt wird, gibt es immer einen Moment, in dem der angezeigte Wert nicht dem aktuellen Input entspricht. Dieser Moment ist der "stale"-Zustand. React stellt keine eingebaute API bereit, um diesen Zustand zu erkennen – aber er lässt sich trivial ableiten: Wenn der aktuelle Query-State und der deferred Wert nicht identisch sind, ist der Inhalt veraltet. Eine einfache Opazitätsreduzierung mit einer CSS-Transition vermittelt dem Nutzer dieses Feedback ohne aufwändige Lade-Spinner.
Für ausgefeilteres Feedback kann man useTransition kombinieren, das einen expliziten isPending-Boolean zurückgibt. Der Unterschied: useTransition wird am Aufrufort des State-Updates eingesetzt und markiert das Update selbst als Transition. useDeferredValue dagegen wird auf dem Wert selbst eingesetzt und ist dann sinnvoll, wenn man auf einen Wert zugreifen muss, den man nicht selbst kontrolliert – etwa eine Prop, die von einer Eltern-Komponente kommt. Der Vergleich query !== deferredQuery ist in beiden Fällen ein zuverlässiger Weg, den Stale-Zustand zu erkennen.
In komplexeren Anwendungen empfiehlt es sich, den Stale-Zustand auch für Screen-Reader-Nutzer kenntlich zu machen. Ein aria-busy="true" auf dem Ergebniscontainer, das gesetzt wird, solange der stale-Zustand aktiv ist, informiert assistive Technologien darüber, dass der Inhalt gerade aktualisiert wird. Das ist eine einfache, aber wichtige Ergänzung für barrierefreie Suchen.
7. useDeferredValue vs. Debounce im direkten Vergleich
Die Wahl zwischen useDeferredValue und Debounce hängt vom Kontext ab. Debounce ist ein festes Zeitfenster – es funktioniert deterministisch und ist einfach zu verstehen. Es eignet sich gut für Netzwerk-Requests, weil man dort explizit vermeiden will, zu viele Requests abzufeuern. Für teure Render-Operationen im Client ist es eine schlechte Wahl, weil es weder mit der React-Render-Pipeline interagiert noch auf Geräteleistung adaptiert.
| Kriterium | Debounce (setTimeout) | useDeferredValue |
|---|---|---|
| Zeitfenster | Fest (z.B. 300 ms) | Adaptiv – je nach Browser-Last |
| React-Integration | Außerhalb des Renderzyklus | Concurrent-Mode-nativ |
| Memory-Leaks | Manueller Cleanup nötig | Kein Cleanup, React verwaltet alles |
| Netzwerk-Requests drosseln | Ideal | Nicht geeignet (kein Request-Throttling) |
| Teure Client-Renders | Suboptimal | Ideal in Kombination mit React.memo |
In der Praxis kombiniert man beide Ansätze: useDeferredValue für den Render-Teil (die teure Ergebnisliste), und Debounce für den Netzwerk-Request (die Autocomplete-API). Das ergibt die optimale Nutzererfahrung: sofortiges Input-Feedback, verzögertes aber reaktionsfähiges Client-Rendering, und kontrollierte API-Abfragen.
8. Typische Fallstricke und wie man sie vermeidet
Der häufigste Fehler beim Einsatz von useDeferredValue ist das Fehlen von React.memo auf der Kind-Komponente. Ohne Memoisierung rendert React die Kind-Komponente bei jedem Render der Eltern-Komponente neu – und der deferred Wert hat keinerlei Effekt. Der Hook verzögert nur den Wert, nicht das Rendering. Das Rendering muss durch React.memo (oder useMemo für teure Berechnungen) selbst verhindert werden.
Ein zweiter häufiger Fallstrick: Das Objekt, das an useDeferredValue übergeben wird, wird bei jedem Render neu erstellt. Da React Objekte per Referenz vergleicht, sieht der Hook bei jedem Render ein "neues" Objekt und plant ein Re-Render – auch wenn sich die Werte nicht geändert haben. Die Lösung ist useMemo, um das Objekt zu stabilisieren. Primitive Werte (Strings, Numbers, Booleans) haben dieses Problem nicht – sie werden per Wert verglichen.
Ein dritter Fallstrick: useDeferredValue hilft nicht bei Netzwerk-Requests. Der Hook verschiebt Render-Arbeit, beeinflusst aber nicht, wann Requests ausgelöst werden. Wenn ein useEffect den Query-State beobachtet und bei jeder Änderung einen Request feuert, wird useDeferredValue das nicht verhindern. Für Request-Throttling ist Debounce oder ein dediziertes Throttling-Pattern (z.B. via AbortController) die richtige Wahl.
import { useState, useDeferredValue, memo, useMemo, useEffect } from 'react';
// WRONG: object created on every render — useDeferredValue sees a new reference
// each time and always schedules a re-render
function BadExample({ query }: { query: string }) {
// New object reference on every render!
const filter = { query, active: true };
const deferredFilter = useDeferredValue(filter); // Always "new"
// ...
}
// RIGHT: stabilise object with useMemo before passing to useDeferredValue
function GoodExample({ query }: { query: string }) {
const filter = useMemo(() => ({ query, active: true }), [query]);
const deferredFilter = useDeferredValue(filter); // Stable reference
// ...
}
// RIGHT: combine useDeferredValue for rendering + debounce for network
function SearchWithAPI({ items }: { items: string[] }) {
const [inputValue, setInputValue] = useState('');
const [apiQuery, setApiQuery] = useState(''); // debounced for API
const deferredInput = useDeferredValue(inputValue); // deferred for rendering
// Debounce only the API call — not the render
useEffect(() => {
const timer = setTimeout(() => setApiQuery(inputValue), 400);
return () => clearTimeout(timer);
}, [inputValue]);
// Use deferredInput for expensive client-side filtering
const clientResults = useMemo(
() => items.filter((i) => i.includes(deferredInput)),
[deferredInput, items]
);
return <div>{/* render clientResults */}</div>;
}
9. TypeScript-Integration und Typsicherheit
useDeferredValue ist vollständig in den React-Typdefinitionen erfasst. Der Hook ist generisch und leitet den Rückgabetyp automatisch aus dem übergebenen Wert ab – keine manuelle Typannotation nötig. In TypeScript-Projekten empfiehlt es sich, die Filterparameter als Interface oder Type-Alias zu definieren und diesen Typ konsequent für den State, den useMemo-Wert und die Kind-Komponenten-Props zu verwenden. Das stellt sicher, dass der Typ beim Hinzufügen neuer Filterparameter an allen Stellen konsistent aktualisiert wird.
Ein häufiger TypeScript-Fehler beim Einsatz von React.memo mit generischen Komponenten: Der Typ der gememoisierten Komponente verliert seine Generizität. Das lässt sich umgehen, indem man die Komponente erst typisiert und dann separat mit React.memo umhüllt, statt beides in einem Ausdruck zu tun. Alternativ kann man die Komponente als Arrow-Function mit explizitem Typ annotieren. Die TypeScript-Typdefinitionen für useDeferredValue ab React 19 schließen den optionalen zweiten Parameter (initialValue) ein – in React 18-Projekten führt die Übergabe eines zweiten Arguments zu einem Typfehler.
Ein guter Ansatz für typsichere Suchen ist die Verwendung eines Discriminated-Union-Types für den Zustand der Suche: { status: 'idle' } | { status: 'searching'; query: string } | { status: 'results'; results: T[] }. Dieser Ansatz macht den Stale-Zustand explizit typsicher und verhindert, dass man auf results zugreift, wenn der Status idle ist. In Kombination mit useDeferredValue entsteht so eine robuste, typsichere Sucharchitektur.
10. Zusammenfassung
useDeferredValue ist das richtige Werkzeug, wenn teure Client-seitige Render-Operationen die UI-Reaktionsfähigkeit beeinträchtigen. Der Hook verzögert einen Wert, sodass React zunächst dringende Updates (Tastatureingaben, Klicks) verarbeitet und dann im Hintergrund mit dem aktualisierten Wert neu rendert. Das Ergebnis ist eine UI, die unter Last reaktionsfähig bleibt – ohne feste Timer, ohne Memory-Leak-Risiko und ohne Interaktion mit dem React-Renderzyklus zu umgehen.
Die drei Voraussetzungen für eine effektive Anwendung: Erstens muss die Kind-Komponente mit React.memo umhüllt sein, damit das Rendering übersprungen werden kann, wenn der deferred Wert noch nicht aktualisiert wurde. Zweitens müssen Objekte, die an useDeferredValue übergeben werden, mit useMemo stabilisiert werden. Drittens ist useDeferredValue kein Ersatz für Debounce bei Netzwerk-Requests – beide Ansätze haben unterschiedliche Aufgaben und können kombiniert werden. Wer diese drei Punkte beachtet, kann Debounce-Heuristiken in React-Anwendungen vollständig durch natives Concurrent-Mode-Scheduling ersetzen.
useDeferredValue — Das Wichtigste auf einen Blick
Grundprinzip
Verzögert einen Wert, damit React dringende Updates (Input) zuerst verarbeitet und teure Renders im Hintergrund plant.
Pflichtpartner React.memo
Ohne React.memo auf der Kind-Komponente hat useDeferredValue keinen Effekt – das Rendering wird nicht übersprungen.
Objekte stabilisieren
Objekte immer mit useMemo stabilisieren bevor sie an useDeferredValue übergeben werden – sonst reagiert der Hook auf jede neue Referenz.
Kein API-Throttling
useDeferredValue drosselt keine Netzwerk-Requests. Für API-Calls Debounce verwenden, für teure Client-Renders useDeferredValue.