Blog
Race Conditions in React: alte Antworten überschreiben den aktuellen Zustand
Der Filter zeigt das Archiv, doch offene Einträge kehren zurück. Anfragezuständigkeit, Abbruch, Ladezustand und Fehler gezielt steuern und konkurrierende Antworten deterministisch testen.
Der Filter zeigt „Archiv“, die Liste enthält offene Einträge
Ein Moderator bearbeitet Bildunterschriften in einem lokalen Geschichtsarchiv. Er öffnet die Liste offener Einträge und wechselt noch vor der ersten Antwort ins Archiv. Zunächst erscheinen archivierte Bildunterschriften. Kurz darauf werden sie durch offene Einträge ersetzt, obwohl weiterhin „Archiv“ ausgewählt ist. Entscheidungen auf dieser Grundlage beruhen auf einer irreführenden Anzeige.
Das fiktive Feature veranschaulicht einen Korrektheitsfehler produktiver Anwendungen, keinen belegten GiSoft-Vorfall. Beide Antworten können für sich genommen stimmen. Der Fehler liegt darin, dass die Seite ein Ergebnis übernimmt, das nicht mehr zur aktuellen Auswahl gehört.
Zeitpunkt Auswahl Abschluss und sichtbares Ergebnis
0 pending A startet
1 archived B startet
2 archived B endet: archivierte Einträge
3 archived A endet: offene Einträge überschreiben sie
Mit Zuständigkeitsprüfung bei 3: A verworfen, Archiv bleibt.Startreihenfolge ist keine Abschlussreihenfolge
A startet vor B, muss aber nicht zuerst fertig werden. Ein Cache Miss, eine langsamere Datenbankabfrage oder wechselnde Mobilfunkbedingungen können die ältere Operation verzögern. Gemeint ist die Abschlussreihenfolge asynchroner Arbeit, nicht eine direkte Steuerung des React-Zustands durch einzelne HTTP-Pakete.
Localhost, kleine Testdaten und Pausen zwischen Klicks können den Fehler verdecken. In Produktion tippen und navigieren Menschen weiter, während Anfragen laufen. Netzwerkdrosselung hilft bei der Untersuchung; ein zuverlässiger Regressionstest sollte die Antwortreihenfolge jedoch unmittelbar kontrollieren.
Dasselbe Problem tritt in Event-Handlern, Route-Loadern, eigenen Hooks, Mutation-Callbacks und gemeinsamen Caches auf. useEffect ist ein möglicher Auslöser der Arbeit, nicht ihre eigentliche Fehlerursache. Mehrere Operationen dürfen denselben Zustand schreiben, ohne dass ihre Zuständigkeit geregelt ist.
Plausibler Code ohne Reihenfolgeregel
Der Beispielvertrag liefert Kurzangaben zu Bildunterschriften. Die folgenden Typen gehören zur fiktiven Artikel-API und können für die zusammenhängenden Beispiele in queueContract.ts liegen. Der Code passt zum React-19- und TypeScript-5.9-Stack des Repositorys.
export type Bucket = 'pending' | 'archived';
export type QueueQuery = { bucket: Bucket; term: string };
export type Caption = { id: string; label: string };
export type QueueReader = (
query: QueueQuery, signal?: AbortSignal,
) => Promise<Caption[]>;
export type QueueView =
| { phase: 'idle' | 'loading' }
| { phase: 'ready'; rows: Caption[] }
| { phase: 'failed'; message: string };useUnsafeQueue.ts ist absichtlich fehlerhaft. Das Laden beginnt nach der Listenauswahl, nicht automatisch beim Mounten. Die Abhängigkeit read wird später durch den HTTP-Adapter erfüllt.
import { useState } from 'react';
import type { QueueQuery, QueueReader, QueueView } from './queueContract';
export function useUnsafeQueue(read: QueueReader) {
const [query, setQuery] = useState<QueueQuery>({ bucket: 'pending', term: '' });
const [view, setView] = useState<QueueView>({ phase: 'idle' });
async function search(next: QueueQuery) {
setQuery(next);
setView({ phase: 'loading' });
try {
const rows = await read(next);
setView({ phase: 'ready', rows });
} catch {
setView({ phase: 'failed', message: 'Bildunterschriften konnten nicht geladen werden. Bitte erneut versuchen.' });
}
}
return { query, view, search };
}Jeder Aufruf hält sein eigenes next fest, alle dürfen aber view ersetzen. Ein verspäteter Erfolg zeigt falsche Zeilen; ein verspäteter Fehler kann einen neueren Erfolg verdecken. Eine zusätzliche Effect-Abhängigkeit oder useMemo entscheidet nicht, welches Ergebnis noch relevant ist.
Die neueste Absicht erhält das Schreibrecht
Auf diesem Bildschirm ersetzt eine neue Listenauswahl oder Texteingabe die vorherige Absicht. Jede bekommt eine eigene Identität; nur diese darf das Ergebnis veröffentlichen. Query-Parameter allein reichen nicht: Jemand kann offene Einträge, das Archiv und erneut offene Einträge auswählen. Erste und dritte Anfrage haben dieselben Parameter, gehören aber zu verschiedenen Interaktionen.
Ein veraltetes Ergebnis zu ignorieren, stoppt die Arbeit nicht. Die Operation verliert lediglich das Recht, diese Ansicht zu verändern. Das hilft auch bei einem nicht abbrechbaren Client oder einer gemeinsam genutzten Anfrage, deren Ergebnis anderen Cache-Abonnenten noch nützt.
useCaptionQueue.ts kombiniert diese Regel mit dem Abbruch unnötiger Leseanfragen. Das Controller-Objekt dient zugleich als eindeutige lokale Kennung. commit() prüft das Schreibrecht; abort() fordert unabhängig davon den Abbruch an. Der Timer wird für die anschließende Autovervollständigung benötigt.
import { useEffect, useRef, useState } from 'react';
import type { QueueQuery, QueueReader, QueueView } from './queueContract';
export function useCaptionQueue(read: QueueReader) {
const [query, setQuery] = useState<QueueQuery>({ bucket: 'pending', term: '' });
const [view, setView] = useState<QueueView>({ phase: 'idle' });
const owner = useRef<AbortController | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => () => {
const previous = owner.current;
owner.current = null;
previous?.abort();
clearTimeout(timer.current);
}, []);
function search(next: QueueQuery, delay = 0) {
const previous = owner.current;
const ticket = new AbortController();
owner.current = ticket;
previous?.abort();
clearTimeout(timer.current);
setQuery(next);
setView({ phase: 'loading' });
function commit(value: QueueView) {
if (owner.current === ticket && !ticket.signal.aborted) {
setView(value);
}
}
async function run() {
try {
const rows = await read(next, ticket.signal);
commit({ phase: 'ready', rows });
} catch {
if (!ticket.signal.aborted) {
commit({ phase: 'failed', message: 'Bildunterschriften konnten nicht geladen werden. Bitte erneut versuchen.' });
}
}
}
if (delay > 0) timer.current = setTimeout(() => { void run(); }, delay);
else void run();
}
return { query, view, search };
}Die Zuständigkeit wechselt synchron mit der neuen Absicht in search(), noch vor einer Debounce-Wartezeit. Erfolg und Fehler passieren dieselbe Prüfung. Beim Unmount widerruft das Cleanup das Schreibrecht auch dann, wenn ein Reader den Abbruch ignoriert. Ein isMounted-Flag unterscheidet keine zwei konkurrierenden Anfragen einer weiterhin gemounteten Komponente.
Der Hook verwaltet eine Ansicht für genau einen Archiv- und Benutzerkontext. Er ist kein allgemeiner Query-Cache. Reader und Archiv müssen für diese Instanz zusammenpassen; ein Archiv- oder Kontowechsel verlangt eine neue Instanz oder eine ausdrückliche Invalidierung des bisherigen Kontexts. Das Query-Objekt wird als unveränderter Stand der Auswahl behandelt.
Überholte Leseanfragen abbrechen, echte Fehler sichtbar lassen
Ein Abbruch erreicht fetch nur, wenn der Adapter das Signal weitergibt. Hier steht captionApi.ts. Der Beispiel-Endpoint liefert ein Array aus { id, label } mit eindeutigen IDs. Die kleine Laufzeitprüfung kontrolliert Feldtypen und übernimmt nur öffentliche Felder; sie ist kein vollständiges Schemasystem.
import type { QueueReader } from './queueContract';
export const readCaptionQueue: QueueReader = async (query, signal) => {
const params = new URLSearchParams({ bucket: query.bucket, q: query.term });
const response = await fetch(`/api/caption-queue?${params}`, {
signal, credentials: 'same-origin', headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error(`QUEUE_HTTP_${response.status}`);
const payload: unknown = await response.json();
if (!Array.isArray(payload) || payload.some((row) =>
row === null || typeof row !== 'object'
|| typeof row.id !== 'string' || typeof row.label !== 'string'
)) throw new Error('QUEUE_SHAPE');
return payload.map((row) => ({ id: row.id, label: row.label }));
};Die Komponente kann readCaptionQueue erhalten. Der Adapter verwendet einen Same-Origin-Endpoint und den vorhandenen Session-Vertrag der Anwendung. Er stellt weder die Authentifizierung her noch lockert er die Zugriffskontrolle.
Beim standardmäßigen Browserabbruch wird das Promise gewöhnlich mit AbortError abgelehnt. Eigene Abbruchgründe und HTTP-Wrapper können davon abweichen. Der Hook prüft sein eigenes Signal, weil er den Anlass des Abbruchs kennt, statt bestimmte Exception-Namen pauschal zu ignorieren. Ein echter Fehler der aktuellen Anfrage bleibt sichtbar. Ein Timeout, über das der Benutzer informiert werden soll, benötigt eine eigene Fehlerbehandlung und darf nicht still als überholte Arbeit verschwinden.
Ein Browserabbruch rollt keine Servertransaktion zurück. Eine Mutation kann bereits angenommen worden sein. Selbst bei Lesezugriffen ist nicht zugesichert, dass jede Datenbankabfrage oder jeder Vermittler aufhört zu arbeiten. Veraltete UI-Schreibzugriffe müssen unabhängig von einer möglichen Ressourcenersparnis verhindert werden.
Debounce begrenzt Anfragen, nicht deren Gültigkeit
Ein Moderator sucht nach „Kanal“: K, Ka, Kan, Kana, Kanal. Ein langsames Ergebnis für Kan darf das Ergebnis für Kanal nicht ersetzen. Debounce bestimmt, wann neue Arbeit startet. Die Zuständigkeitsprüfung bestimmt, welches Ergebnis die Ansicht ändern darf.
Leicht übersehen wird die Lücke dazwischen: Kan läuft bereits, der Benutzer tippt Kanal, und die alte Antwort trifft während der neuen Debounce-Frist ein. Die alte Operation erst beim nächsten fetch zu entwerten, wäre zu spät. Unser Hook erledigt das beim Eingabeereignis.
Das vollständige CaptionQueue.tsx lädt bei den Listenbuttons sofort und verwendet für Texteingaben beispielhaft 180 ms Verzögerung. Währenddessen entfernt es bewusst die bisherigen Zeilen. Frühere Ergebnisse dürfen auch stehen bleiben, wenn das Produkt es vorsieht; sie müssen dann als solche erkennbar sein und dürfen nicht zum neuen Filter passend erscheinen.
import type { QueueReader } from './queueContract';
import { useCaptionQueue } from './useCaptionQueue';
export function CaptionQueue({ read }: { read: QueueReader }) {
const { query, view, search } = useCaptionQueue(read);
return (
<section>
<button type="button" onClick={() => search({ ...query, bucket: 'pending' })}>
Offen
</button>
<button type="button" onClick={() => search({ ...query, bucket: 'archived' })}>
Archiv
</button>
<output aria-label="Gewählte Liste">
{query.bucket === 'pending' ? 'Offen' : 'Archiv'}
</output>
<input aria-label="Bildunterschrift" value={query.term}
onChange={(event) => search({ ...query, term: event.target.value }, 180)} />
{view.phase === 'loading' && <p role="status">Bildunterschriften werden geladen…</p>}
{view.phase === 'failed' && <p role="alert">{view.message}</p>}
{view.phase === 'ready' && <ul>
{view.rows.map((row) => <li key={row.id}>{row.label}</li>)}
</ul>}
</section>
);
}Der Hook-Timer steuert das Verhalten, doch der Test muss keine echte Zeit abwarten. Debounce wird mit einer kontrollierten Testuhr geprüft, die Abschlussreihenfolge mit kontrollierten Promises. Alle Suchanfragen hinter der ältesten einzureihen, würde Überlappung vermeiden, aber jede Eingabe auf ein bereits irrelevantes Ergebnis warten lassen.
Daten, Ladezustand und Fehler brauchen dieselbe Zuständigkeit
A startet, danach B. A wird fertig, während B noch läuft. Ein bedingungsloses finally { setLoading(false) } würde den Ladeindikator für B entfernen. Im korrigierten Hook darf eine alte Operation keine Phase mehr schreiben; die Ansicht bleibt loading, bis ihre eigene Operation endet.
Für ein Dashboard mit drei unabhängig ladenden Bereichen kann ein Zähler laufender Operationen oder ein Zustand pro Bereich besser passen. Eine globale Regel „nur die neueste Anfrage zählt“ würde dort benötigte Ergebnisse verwerfen.
Fehler unterliegen demselben Rennen. B kann erfolgreich sein, bevor A fehlschlägt. Der späte Fehler darf korrekte Archivdaten nicht durch eine Fehlermeldung ersetzen. Daten, Fehler und Laden bilden einen zusammengehörigen Operationszustand. Wer sie ohne Zuständigkeitsprüfung getrennt aktualisiert, erzeugt denselben Fehler an einer weniger sichtbaren Stelle erneut.
Navigation, abhängige Abfragen und festgehaltene Werte
Eine Anfrage der vorherigen Route kann nach der Navigation enden. Unmount-Cleanup schützt den lokalen Zustand dieses Hooks; ein dauerhaftes Layout oder gemeinsamer Store kann die Seite jedoch überleben. Archiv, ausgewähltes Objekt, Filter und relevanter Benutzerkontext gehören zur Zuständigkeits- oder Cache-Identität. Wird ihre Bedeutung durch Navigation geändert, muss der alte Kontext ungültig werden. Ein anderer Pfad bedeutet nicht immer, dass die Komponente unmountet.
Abhängige Anfragen erfordern dieselbe Sorgfalt. Lädt eine Fotoauswahl zuerst Metadaten und danach Bildunterschriften, muss die gesamte Kette die Identität dieses Fotos behalten. Prüfe die Relevanz vor dem zweiten Lesezugriff und vor der Veröffentlichung; gib das Abbruchsignal weiter, wo es unterstützt wird.
Callbacks sehen Werte aus dem Renderdurchlauf, in dem sie entstanden sind. Nach setSelectedPhoto(next) kann loadCaptions(selectedPhoto.id) weiterhin die vorherige ID verwenden. Für diese Aktion ist next.id ausdrücklich zu übergeben. Ein funktionaler State-Updater hilft bei Berechnungen aus dem bisherigen Zustand, entscheidet aber nicht, zu welchem Foto eine Serverantwort gehört. Eine unterdrückte Linter-Warnung zu Abhängigkeiten behebt keines dieser Probleme.
Optimistische Änderungen brauchen einen anderen Vertrag
Ein Moderator ändert die Prüfpriorität einer Bildunterschrift von normal auf hoch und anschließend auf dringend. Das UI zeigt beide Änderungen sofort. Trifft die Antwort für hoch zuletzt ein und ersetzt die Zeile, springt die Anzeige zurück. Ebenso kann ein pauschaler Rollback nach dem Fehlschlag der ersten Mutation die spätere Bearbeitung rückgängig machen.
Mögliche Entwürfe sind eine Mutationsversion pro Ressource, ein Verzeichnis ausstehender Operationen oder die sequenzielle Ausführung von Änderungen derselben Ressource. Ein Rollback darf nur seine eigene Operation betreffen, statt einen vollständigen alten Snapshot über neuere Arbeit zu legen. Eine Cache-Bibliothek kann Teile davon bereitstellen; ihre Mutationssemantik muss trotzdem zum Feature passen.
Die ältere Antwort zu ignorieren, schützt die Anzeige, nicht den endgültigen Serverzustand. Direkt nach der neuesten Client-Mutation neu zu laden, reicht nicht, wenn ein früherer Schreibzugriff noch später committen kann. Gleiche den Zustand nach Abschluss der relevanten Schreibvorgänge ab oder verwende Serverversionen mit einem definierten Konfliktverfahren. Ein altes Payload darf nicht blind über die akzeptierte Änderung eines anderen Benutzers geschrieben werden.
Abbruch, Wiederholung und Geschäftsvorgang auseinanderhalten
Ein deaktivierter Submit-Button reduziert versehentliche Doppelklicks. Zwei Tabs, zwei Komponenten oder einen anderen Benutzer koordiniert er nicht. Duplikate können zudem durch explizite Client-Retries, Neuladen, konfigurierte Proxy-Retries oder erneute Backend-Zustellung entstehen. Das hängt vom System ab und ist keine allgemeine Browserregel.
Beispiel: POST /api/captions/28/approve. Der Benutzer navigiert weiter, und der Browser wartet nicht mehr. Die Freigabe kann bereits gespeichert und eine Benachrichtigung beauftragt sein. Vor einer Wiederholung mit unklarem Ausgang sollte das Operationsergebnis abgefragt werden, soweit die API dies ermöglicht; maßgeblich ist deren Retry-Vertrag.
Drei Identitäten sind zu unterscheiden. Eine Request-ID verbindet Ereignisse eines Transportversuchs. Eine View-Intent-ID bezeichnet die aktuell für den Bildschirm zuständige Auswahl. Eine Geschäftsoperations-ID beziehungsweise ein Idempotenzschlüssel bezeichnet dieselbe logische Freigabe über mehrere Versuche hinweg. Ein neuer View-Marker ersetzt keinen stabilen Retry-Schlüssel. Dessen Gültigkeitsbereich, Payload-Konsistenz und Duplikatbehandlung muss das Backend durchsetzen.
Bei konkurrierenden Änderungen kann eine mit Revision 12 gelesene Bildunterschrift beim Speichern bereits Revision 13 haben. Eine Prüfung der erwarteten Version oder ein bedingtes Update kann den veralteten Schreibzugriff ablehnen; der API-Vertrag legt die Konfliktantwort fest. Berechtigungen, Eindeutigkeit und atomare Datenbankänderungen bleiben Serveraufgaben. UI-Abbruch ersetzt sie nicht. Zustellungsdetails behandelt der bestehende GiSoft-Artikel zu Messenger.
Eine maßgebliche Quelle für den Bildschirmzustand wählen
Gibt die URL den Filter vor, sollten lokale Bedienelemente und Query-Key ihn abbilden. Verwaltet ein Formular ungespeicherte Änderungen, darf ein Hintergrund-Refresh sie nicht still überschreiben. Ein Query-Cache kann Serversnapshots verwalten; für den festgeschriebenen Geschäftszustand bleibt das Backend maßgeblich. Unabhängige Kopien derselben Zeilen in Context, Komponente und Cache erschweren den Abgleich.
Query-Bibliotheken können Deduplizierung, Cache-Identitäten, Abbruch und Mutationswerkzeuge bereitstellen. Keine davon korrigiert einen unvollständigen Schlüssel: Offene und archivierte Ergebnisse dürfen nicht denselben undifferenzierten Cache-Eintrag verwenden. Im Repository sind weder TanStack Query noch SWR deklariert, deshalb fügen die Beispiele sie nicht hinzu. Verwaltet bereits eine Bibliothek die Daten, sollten ihre vorgesehenen Mechanismen genutzt werden.
Beim Next.js App Router sind Client-Filter mit Routenparametern und Server-Refreshes abzustimmen. Ein Route-Refresh kann unberührten Client-Zustand erhalten und invalidiert nicht von sich aus serverseitige Caches. Das Verhalten hängt von Routingmodus, Version und Konfiguration ab. Die Beispiele gehören in einen Client-Teilbaum; read kommt aus Client-Code und wird nicht als gewöhnliche Funktion über eine Server-Component-Grenze gereicht.
Transitions betreffen die Renderpriorität. Eigenständig koordinierte asynchrone Arbeit braucht weiterhin eine Reihenfolgeregel; höher angesiedelte Actions können eigene Ordnungsregeln anbieten. Ein beliebiges fetch in startTransition einzuschließen, lässt nicht automatisch die letzte Benutzeraktion gewinnen.
Umgekehrte Abschlussreihenfolge gezielt testen
Wir müssen bestimmen können, wann jede Antwort bereitsteht. ReplyGate.ts ist ein eigens für die Beispiele formulierter Testhelfer, keine Vitest- oder React-API. Er kann ein Promise erfüllen oder ablehnen:
export class ReplyGate<T> {
private accept!: (value: T) => void;
private decline!: (reason: Error) => void;
readonly result = new Promise<T>((resolve, reject) => {
this.accept = resolve;
this.decline = reject;
});
deliver(value: T) { this.accept(value); }
fail(reason: Error) { this.decline(reason); }
}Der Test verwendet Vitest, React Testing Library, user-event und eine DOM-Umgebung mit @testing-library/jest-dom/vitest im Setup. Er importiert die zuvor gezeigte Komponente und den Vertrag. Der Reader-Double ignoriert Abbruch absichtlich: So wird die Zuständigkeitsprüfung auch bei dennoch abgeschlossener Arbeit überprüft.
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { CaptionQueue } from './CaptionQueue';
import type { Caption, QueueReader } from './queueContract';
import { ReplyGate } from './ReplyGate';
it('zeigt weiter das Archiv trotz verspäteter Antwort der offenen Liste', async () => {
const inbox = new ReplyGate<Caption[]>();
const archive = new ReplyGate<Caption[]>();
const read = vi.fn<QueueReader>((query) =>
query.bucket === 'pending' ? inbox.result : archive.result,
);
const user = userEvent.setup();
render(<CaptionQueue read={read} />);
await user.click(screen.getByRole('button', { name: 'Offen' }));
await user.click(screen.getByRole('button', { name: 'Archiv' }));
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls[0][1]?.aborted).toBe(true);
await act(async () => {
archive.deliver([{ id: 'caption-28', label: 'Fußgängerbrücke am Kanal' }]);
});
expect(screen.getByLabelText('Gewählte Liste')).toHaveTextContent('Archiv');
expect(screen.getByText('Fußgängerbrücke am Kanal')).toBeVisible();
await act(async () => {
inbox.deliver([{ id: 'caption-11', label: 'Bildunterschrift zur Haltestelle' }]);
});
expect(screen.getByText('Fußgängerbrücke am Kanal')).toBeVisible();
expect(screen.queryByText('Bildunterschrift zur Haltestelle')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});Die Zwischenprüfungen sind entscheidend: erst B sichtbar machen, dann A liefern und prüfen, dass B bleibt. Nur die abschließende Filterbeschriftung zu prüfen, würde falsche Zeilen übersehen. Weder echte Netzwerklatenz noch willkürliche Wartezeiten bestimmen den Ablauf.
Weitere Rennen auf der passenden Testebene absichern
Dieselben Gates reichen für drei gezielte Fälle. Beim Laden wird A geliefert, während B offen bleibt: Der Ladeindikator muss bleiben und erst nach B verschwinden. Beim Fehlerfall wird B geliefert und A anschließend abgelehnt: B bleibt ohne Alert sichtbar. Beim Abbruch lässt der Adapter-Double sein Promise nach dem Abort-Signal scheitern: Es erscheint kein allgemeiner Fehler, und B kann weiterhin erfolgreich sein. Prüfe außerdem das Unmount-Cleanup, ohne historische React-Warnmeldungen vorauszusetzen.
Für Autovervollständigung wird die Testuhr bewusst bewegt: Kan starten, Kanal eingeben, Kan vor Ablauf des neuen Timers liefern und sicherstellen, dass es nicht erscheint. Danach bis zur konfigurierten Frist vorspulen und das aktuelle Ergebnis liefern. Ein zusätzlicher Fall offen → Archiv → offen schützt davor, gleiche Parameter mit derselben Absicht zu verwechseln.
Risiko Geeignete Testgrenze
Zuständigkeit / Reducer Unit, sofern separat extrahiert
Alte Daten, Fehler, Laden React-Komponente
Signal und Antwortmapping Adapter / Vertrag
Erneute Freigabe / Änderung Backend: funktional / Integration
Echter Moderationsablauf Ausgewählter BrowsertestEin Unit-Test lohnt sich für einen reinen Koordinator oder Reducer; nur für mehr Tests muss keiner extrahiert werden. Adaptertests prüfen Signalweitergabe, Antwortvalidierung, Fehlercodes und gegebenenfalls Versions- oder Idempotenzheader des tatsächlichen Vertrags. Backend-Deduplizierung beweisen sie nicht. Dafür sind Servertests nötig, bei entsprechendem Risiko auch mit Nebenläufigkeit.
Ein Browsertest kann das echte Feature mit kontrollierten, abgefangenen Antworten prüfen, wenn die vorhandenen Werkzeuge das unterstützen. Die genaue Abschlussreihenfolge gehört überwiegend in Komponententests. Großzügige Zeittoleranzen und willkürliche Millisekundenpausen ersetzen keine ausdrückliche Antwortkontrolle. Die GiSoft-Artikel Next.js-Architektur, Fehlerfälle und Benutzerabläufe testen und Symfony-APIs mit Pest testen vertiefen diese weiteren Grenzen.
Erst die Folge rekonstruieren, dann das Timing ändern
Begrenzte Metadaten sollten Absicht und Abschluss rekonstruierbar machen: lokale Sequenz, Korrelations-ID, sichere Query-Kategorie oder bereinigter Schlüssel, Routenkontext, Beginn und Ende sowie die Entscheidung „übernommen“ oder „verworfen“. Bei Bedarf kommen Antwortrevision und Geschäftsoperations-ID hinzu. Ein verworfenes altes Ergebnis kann korrektes Verhalten sein und muss keinen Serverfehler bedeuten.
Suchtext, Bildbeschreibungen und Kontodaten können sensibel sein. Zugangsdaten, Tokens, vollständige Payloads und unnötige personenbezogene Daten gehören nicht in Diagnoseprotokolle. Browser-Timings zusammen mit lokalen Sequenznummern erklären oft mehr als vollständige Antwortdumps.
Mehrere naheliegende Korrekturen lösen ein anderes Problem. Längeres Debounce reduziert Aufrufe, verhindert aber keine Überlappung. useMemo ordnet keine Promises. isMounted unterscheidet Lebenszyklen, nicht konkurrierende Absichten. Ein unbedingtes finally kann den Ladezustand einer anderen Anfrage löschen. Ein deaktivierter Button verbessert die Bedienung, liefert jedoch keine Backend-Idempotenz. Jede Mutation abzubrechen, kann dem Client die Kenntnis eines bereits gespeicherten Ergebnisses nehmen. Jedes Werkzeug braucht eine konkrete Aufgabe.
Kurzer Check vor der Auslieferung
- Aktuelle Absicht und den von ihr ersetzbaren Zustand benennen.
- Ältere Schreibrechte beim Wechsel dieser Absicht widerrufen, auch während Debounce.
- Dieselbe Relevanzprüfung auf Zeilen, Laden und Fehler anwenden; sinnvolle Leseabbrüche ergänzen.
- Backend-Verhalten für unklare Mutationen, Retries und konkurrierende Bearbeitung festlegen.
- Umgekehrten Abschluss, späte Fehler und überlappendes Laden kontrolliert testen.
Die zuletzt eintreffende Antwort erhält nicht automatisch das Recht, den Bildschirm zu verändern. Dieses Recht muss ausdrücklich geregelt sein; Geschäftseffekte sind unabhängig davon auf dem Server zu schützen.
Technische Referenzen
Feature und Beispiele wurden für diesen Artikel entwickelt. Framework- und Abbruchdetails wurden anhand folgender Dokumentation geprüft:
- React, Effect-Cleanup: https://react.dev/reference/react/useEffect
- React, Reihenfolge asynchroner Transitions: https://react.dev/reference/react/useTransition
- MDN, AbortController-Semantik: https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
- Next.js App Router, Refresh-Verhalten: https://nextjs.org/docs/app/api-reference/functions/use-router
