Das Beheben von Rennbedingungen – Debouncing löst das in Such-UIs nicht.
Erfahren Sie, warum allein das Debouncing nicht verhindern kann, dass veraltete API-Antworten den aktuellen UI-Zustand überschreiben, und entdecken Sie vier praktische Lösungen zur Sicherstellung der Anfragereihenfolge.
Letztes Jahr habe ich bei einem Projekt zu einer Suchleiste eine Verzögerung von 300 Millisekunden hinzugefügt und das Problem für gelöst gehalten.
Ein Tester berichtete später von etwas Seltsamem: Wenn er schnell tippte, zeigten die Ergebnisse kurzzeitig etwas Unverwandtes an, bevor sie wieder richtig angezeigt wurden.
Zuerst herrschte Unglaube. Ein Blick in das Netzwerk-Tab zeigte, dass die Verzögerung genau das tat, wofür sie vorgesehen war – eine Anfrage pro Pause, ohne zusätzlichen Verkehr.
Das Problem war jedoch tatsächlich vorhanden. Es handelte sich nur nicht um das Problem, das behoben worden war.
Jeder, der bereits eine Suchleiste entwickelt hat, kennt diese Situation. Jeder Tastendruck löst einen Netzwerkaufruf aus, wodurch die Backend-Systeme überlastet werden.
Der übliche Rat lautet „füge einfach eine Verzögerung hinzu“. Man setzt eine kurze Wartezeit ein, die Anzahl der Anfragen sinkt und das Ticket wird geschlossen.
Kleine Entscheidungen wie diese häufen sich auf eine Weise, die leicht unterschätzt wird. Diese spezielle Entscheidung erscheint unbedeutend, kann aber mehr Schaden anrichten, als es den Anschein hat.
Was eine Verzögerungsfunktion eigentlich nie garantiert, ist Folgendes: Sie steuert wann eine Anfrage gesendet wird. Über welche Antwort zuerst ankommt hat sie keine Kontrolle.
Was Suchleisten mit Verzögerungsfunktion tatsächlich beheben
Durch die Verwendung einer Verzögerungsfunktion bei Suchleisten wird genau ein Problem gelöst: eine Überzahl an ausgesendeten Anfragen.
Das ist der gesamte Umfang dessen, was sie bewirkt. Die Reihenfolge der Anfragen bleibt unverändert.
So kann es genau genommen schiefgehen. Nehmen wir ein Verzögerungsfenster von 300 Millisekunden an.
- Sie tippen
reaein und halten dann 300 Millisekunden lang inne. Anfrage A wird fürreagesendet. - Sie tippen weiter:
react. Nach einer weiteren Pause wird Anfrage B fürreactgesendet.
react anzeigt.rea.Das Eingabefeld zeigt react an, doch die Liste darunter zeigt Ergebnisse für rea. Dieser Unterschied ist das Flackern, das der Tester bemerkt hat.
Debouncing verringert die Häufigkeit, mit der dies geschieht, beseitigt aber nicht die Möglichkeit. Und „seltener“ ist genau die Art von Defekt, der während der Entwicklung verborgen bleibt, da die API sofort antwortet und nur eine Person testet.
Es ist unklar, ob sich dieses Verhalten in jeder Kombination aus Frameworks und Tools identisch verhält.
In React-Projekten, die fetch oder axios verwenden, tritt dieses Muster jedoch jedes Mal konsequent auf.
Es handelt sich dabei nicht nur um ein Problem mit der Suchleiste
Sobald man dieses Versagen erkennt, bemerkt man es an vielen anderen Stellen. Es handelt sich um denselben zugrundeliegenden Fehler in unterschiedlichen Erscheinungsformen:
- Filter-Dropdowns. Ein Benutzer wählt „Schuhe“ aus und wechselt anschließend schnell zu „Taschen“. Wenn die Anfrage für Schuhe zuletzt abgeschlossen wird, werden die Schuhlisten unter dem Label „Taschen“ angezeigt.
- Tabs. Jemand klickt auf „Bestellungen“ und klickt dann sofort auf „Rechnungen“. Wenn die Antwort für die Bestellungen langsamer kommt, gewinnt sie dennoch und es werden falsche Daten unter dem richtigen Tab angezeigt.
Sobald ein Benutzer seine Anfrage schneller ändern kann, als der Backend-Server reagieren kann, tritt immer wieder diese gleiche Rennbedingung auf.
In echten Anwendungen wird es noch schwieriger, solche Probleme zu lösen.
Stellen Sie sich ein Admin-Dashboard vor, in dem jemand auswählt:
- Letzte 30 Tage
- Dann letzte 7 Tage
- Dann heute
Zu diesem Zeitpunkt könnten gleichzeitig drei separate Anfragen unterwegs sein.
Selbst genaue Daten werden zu falschen Daten, sobald sie einer Aktion des Benutzers entsprechen, die dieser bereits abgeschlossen hat.
Warum eine längere Verzögerung nicht hilft
Der erste Impuls ist in der Regel, das Debounce-Fenster zu vergrößern – man versucht zunächst 500 ms und dann vielleicht 800 ms.
Diese Anpassung, die hauptsächlich aus Gewohnheit und nicht aus logischem Nachdenken resultiert, trug dazu bei, dass die Häufigkeit des Flimmerns nicht abnahm. Sie sorgte lediglich dafür, dass die Benutzeroberfläche träge wirkte. Eine Anfrage, die den Cache verfehlt und 900 ms dauert, überdauert weiterhin ein Debounce-Fenster von 800 ms.
Durch das Anpassen eines Zeitwerts lässt sich ein Problem, das in der Reihenfolge liegt, nicht lösen.
In diesem Moment wird die wahre Natur des Fehlers klar.
Ein Debounce ist ein Verkehrsregelungsmechanismus.
Eine Rennbedingung ist ein Reihenfolgefehler.
Durch Verlängerung der Zeit, in der eine Ampel rot bleibt, wird nicht bestimmt, welches Auto zuerst am Ziel ankommt.
Dafür ist eine völlig separate Regel erforderlich.
Die echte Lösung: Nur der neueste Anfrage ist zur Aktualisierung der Benutzeroberfläche erlaubt
Dieser eine Satz ist die gesamte Lösung. Mehrere Techniken sorgen dafür, wobei sie von einfach bis umfassend reichen.
Lösung 1: Jeder Anfrage wird eine Ticketnummer zugewiesen
Das ist die einfachste Variante, die man zuerst erklären kann, denn sie macht die zugrunde liegenden Mechanismen deutlich.
import { useState, useRef } from "react";
function useSearch() {
const [results, setResults] = useState([]);
const latestRequestId = useRef(0);
async function search(query) {
const myId = ++latestRequestId.current;
const res = await fetch(`/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
// If a newer request started while we were waiting, throw this away.
if (myId === latestRequestId.current) {
setResults(data.results);
}
}
return { results, search };
}
Jeder ausgehende Anfrage wird eine Ticketnummer zugewiesen.
Wenn eine Antwort zurückkommt, fragt sie sich: „Habe ich immer noch das neueste Ticket?“
Falls die Antwort Nein lautet, wird die Antwort verworfen. Die Daten an sich sind nicht falsch – sie sind einfach veraltet. Und veraltete Daten sehen genauso aus wie falsche Daten, sobald sie auf dem Bildschirm angezeigt werden.
Lösung 2: Die alte Anfrage mit AbortController abbrechen
Das Überprüfen von Ticketnummern ignoriert einfach die veralteten Antworten, sobald sie eintreffen. AbortController verhindert außerdem, dass sie überhaupt eintreffen. Hier ist die Idee in reinem JavaScript, ohne Einsatz eines Frameworks:
let controller = null;
async function search(query) {
// Kill whatever is still in flight.
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
renderResults(data.results);
} catch (err) {
// An aborted fetch throws. That's expected, not an error.
if (err.name === "AbortError") return;
showError(err);
}
}
Ein Detail, das leicht übersehen wird: Ein fetch-Aufruf, der abgebrochen wird, lehnt mit einem AbortError ab. Wenn man die Handhabung dieses Falls übersieht, werden bei jeder Tastenbetätigung falsche Fehler protokolliert.
Auch sollte man bedenken, dass ein Abbruch lediglich verhindert, dass der Browser auf die Antwort wartet – der Server könnte den Anfrageverarbeitungsvorgang dennoch fortsetzen. Das ist für eine Suchfunktion unbedenklich, wird aber bedeutender, wenn die Anfrage das Schreiben von Daten beinhaltet.
Lösung 3: Die React-Methode – useEffect-Cleanup
Wenn Ihre Suchlogik innerhalb eines Effects liegt, bietet React bereits ein eingebautes Mechanismus für genau dieses Szenario: die Cleanup-Funktion. Dies ist der Ansatz, der direkt in Reacts eigener Dokumentation empfohlen wird.
import { useState, useEffect } from "react";
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const debouncedQuery = useDebouncedValue(query, 300);
useEffect(() => {
const q = debouncedQuery.trim();
if (!q) {
setResults([]);
return;
}
let ignore = false;
const controller = new AbortController();
fetch(`/search?q=${encodeURIComponent(q)}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => {
if (!ignore) setResults(data.results);
})
.catch((err) => {
if (err.name !== "AbortError") console.error(err);
});
// Runs when debouncedQuery changes again, or the component unmounts.
return () => {
ignore = true;
controller.abort();
};
}, [debouncedQuery]);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</>
);
}
Beachten Sie, was dieses Muster Ihnen automatisch bringt. Es wird der value selbst gedämpft, nicht die Funktion, die den Anfrageablauf auslöst.
Das bedeutet, dass jedes Mal, wenn sich der gedämpfte Wert ändert, zuerst die Cleanup-Funktion aus dem vorherigen Lauf ausgeführt wird und diese veraltete Anfrage storniert. Das Entfernen des Components triggert dieselbe Cleanup-Aktion. Sie benötigen weder Refs noch manuell inkrementierte Zähler, damit es funktioniert.
Lösung 4: Lassen Sie eine Datenabruf-Bibliothek das übernehmen
Falls Ihr Projekt bereits auf TanStack Query (React Query) angewiesen ist, erhalten Sie diese Garantie kostenlos – ohne zusätzliche eigene Logik nötig.
Sobald sich der Abfrageschlüssel ändert, wird die vorherige Abfrage veraltet, und die Bibliothek übermittelt automatisch Ihrer Abruffunktion ein Signal, das diese für Sie abbricht.
import { useQuery, keepPreviousData } from "@tanstack/react-query";
function useSearch(debouncedQuery) {
return useQuery({
queryKey: ["search", debouncedQuery],
queryFn: ({ signal }) =>
fetch(`/search?q=${encodeURIComponent(debouncedQuery)}`, { signal })
.then((r) => r.json()),
enabled: debouncedQuery.trim().length > 0,
placeholderData: keepPreviousData, // v5 replacement for keepPreviousData: true
});
}
Die eigentliche Arbeit findet an zwei Stellen statt: das in queryFn übergebene Signal sowie die Option placeholderData: keepPreviousData, die die vorherige Liste sichtbar hält, anstatt den Bildschirm leer werden zu lassen, während die nächste Ergebnismenge geladen wird.
SWR bietet dasselbe Verhalten durch seine eigene Einstellung keepPreviousData.
Kleine Gewohnheiten, die es viel einfacher gemacht haben, das Richtige zu tun
Im Folgenden sind Gewohnheiten aufgeführt, die in jede Suchimplementierung integriert werden sollten. Keine davon ist besonders clever, aber jede hat zu einem bestimmten Zeitpunkt einen echten Fehler verhindert.
1. Kodieren Sie die Abfragesequenz immer. Ein fehlender Leerzeichen oder ein &-Zeichen innerhalb des Suchbegriffs kann Ihre URL zerstören. Umhüllen Sie den Wert jedes Mal mit encodeURIComponent(query), wenn Sie die Anfrage erstellen. Eine Produktivsuchleiste kann bereits bei einer einfachen Eingabe wie „R&D“ versagen.
2. Ignorieren Sie leere oder zu kurze Abfragen. Entfernen Sie Leerzeichen aus der Eingabe und vermeiden Sie das Senden einer Anfrage bei leeren Zeichenketten. Durch Festlegung einer Mindestlänge, beispielsweise von zwei Zeichen, werden überflüssige Anfragen reduziert und das Risiko von Konflikten verringert.
3. Zeigen Sie weiterhin die vorherigen Ergebnisse an, während eine neue Anfrage geladen wird. Vermeiden Sie es, die Liste bei jeder Tastenbetätigung zu löschen. Ein kleiner Ladeindikator neben dem Eingabefeld ist weitaus weniger störend als ein leeres Bildschirm, das bei jeder Suche wechselt – eine leicht veraltete Liste ist immer noch besser als gar keine. Genau dieses Verhalten bietet keepPreviousData.
4. Cachen Sie die bereits heruntergeladenen Ergebnisse. Die Nutzer löschen häufig Zeichen und tippen sie erneut ein. Ein einfacher Map, der nach dem Suchbegriff sortiert ist, sorgt dafür, dass das Tippen von rea, anschließend react und danach das Zurückspulen auf rea nicht zweimal zum Server für dieselbe Abfrage führt.
const cache = new Map();
async function searchWithCache(query, signal) {
if (cache.has(query)) return cache.get(query);
const res = await fetch(`/search?q=${encodeURIComponent(query)}`, { signal });
const data = await res.json();
cache.set(query, data);
return data;
}
5. Lassen Sie den Backend-Server die Abfrage zusammen mit der Antwort zurücksenden. Anstatt ein einfaches Array zurückzugeben, antworten Sie mit etwas wie { query: "react", results: [...] }. Dadurch kann der Client eine weitere Überprüfung durchführen – if (data.query !== currentQuery) return; – was als günstige zusätzliche Sicherheitsmaßnahme dient und jeden Fehler sofort in der Netzwerkansicht sichtbar macht.
6. Erstellen Sie absichtlich wieder eine Rennbedingung. Es ist unwahrscheinlich, dass Sie diesen Fehler unter localhost bei einer API, die innerhalb von fünf Millisekunden antwortet, bemerken. Fügen Sie Ihrem Mock-Server eine künstliche zufällige Verzögerung hinzu, und das Problem tritt fast sofort zutage:
app.get("/search", async (req, res) => {
await new Promise((r) => setTimeout(r, Math.random() * 2000));
res.json({ query: req.query.q, results: fakeResults(req.query.q) });
});
Außerdem können Sie die Chrome DevTools öffnen, zu „Network“ gehen, das Throttling aktivieren, „Slow 3G“ auswählen und schnell tippen. Dann werden Sie sehen, dass es fehlschlägt.
7. Drücken Sie Enter, um die Verzögerung beim Debounce zu umgehen. Wenn ein Benutzer auf Enter klickt, sollte die Suche sofort ausgelöst werden – Debounce-Verzögerungen dienen dazu, kontinuierliches Tippen zu verarbeiten, nicht einer expliziten Absichtshinweisung.
8. Stornieren Sie immer beim Entfernen der Komponente. Wenn ein Benutzer während einer laufenden Suche weggeht, versucht die anschließende Antwort, setState in einer nicht mehr existierenden Komponente aufzurufen. Die in Fix 3 gezeigte Aufräumfunktion behandelt diesen Fall bereits. Falls Sie stattdessen den Ansatz mit der Ticket-Nummer verwenden, stellen Sie sicher, dass auch die Aufräumfunktion beim Entfernen der Komponente den Zähler erhöht.
9. Wenden Sie useDeferredValue an, wenn Sie lokal filtern und nicht über das Netzwerk. Wenn Sie eine Liste filtern, die bereits in den Speicher geladen wurde, benötigen Sie weder Debouncing noch die Stornierung von Anfragen. useDeferredValue sorgt dafür, dass das Tippen reibungslos bleibt, während React die Filterung im Hintergrund erledigt – doch es ersetzt die Stornierungslogik nicht, sobald eine Serveranfrage involviert ist.
10. Schreiben Sie einen einzigen Test, der direkt die Race Condition adressiert. Mocken Sie zwei fetch-Aufrufe und beenden Sie den zweiten vor dem ersten. Überprüfen Sie, dass die Benutzeroberfläche schließlich das Ergebnis des zweiten Aufrufs anzeigt. Es handelt sich um einen kurzen Test, doch er kann Regressionen aufdecken, die sonst bei der Code-Überprüfung übersehen werden könnten.
Falls Ihre Suchleiste bereits einmal kurzzeitig falsche Ergebnisse anzeigte, bevor sie sich korrigierte, liegt das Problem nicht am Debounce-Verzögerungseffekt, und auch das Hinzufügen weiterer Verzögerungen wird es nicht beheben.
Das Debouncing von Sucheingaben bleibt dennoch eine gute Praxis – es verringert erheblich die Anzahl der gesendeten Anfragen. Es löst das Problem jedoch nicht von allein vollständig.
Was Sie außerdem benötigen, ist eine explizite Sortierregel: Nur die neueste Anfrage darf die Benutzeroberfläche aktualisieren. Dies können Sie mit einer Request-ID, einem AbortController, einer useEffect-Cleanup-Funktion oder einer Bibliothek, die dieses Muster bereits für Sie implementiert hat, durchsetzen. Jeder dieser Ansätze funktioniert – wichtig ist, einen auszuwählen und konsequent anzuwenden.