Blog
Requêtes concurrentes dans React : quand une ancienne réponse écrase l’état courant
Le filtre indique les archives, mais les éléments en attente reviennent. Maîtriser réponses, annulation, chargement et erreurs, puis tester leur ordre sans dépendre de la latence.
Le filtre indique « Archives », mais la liste affiche les éléments en attente
Un modérateur traite les légendes de photos d’un fonds d’histoire locale. Il ouvre la file en attente, puis passe aux archives avant la première réponse. Les légendes archivées apparaissent. Un instant plus tard, des éléments en attente les remplacent, alors que le filtre indique toujours « Archives ». Toute décision repose désormais sur un écran trompeur.
Cette fonctionnalité fictive illustre un défaut de cohérence rencontré en production, pas un incident attesté chez GiSoft. Les deux réponses peuvent être valides. Le problème vient de l’acceptation d’un résultat qui ne correspond plus au choix courant.
Moment Sélection Fin de requête et résultat affiché
0 pending A démarre
1 archived B démarre
2 archived B finit : légendes archivées
3 archived A finit : remplacées par celles en attente
Avec contrôle du propriétaire en 3 : A ignorée, archives conservées.L’ordre de départ ne détermine pas l’ordre d’arrivée
A démarre avant B, sans devoir finir avant elle. Une absence dans le cache, une requête SQL plus lente ou les variations du réseau mobile peuvent retarder l’ancienne opération. Il s’agit de l’ordre d’achèvement du travail asynchrone, et non d’un lien direct entre l’ordre des paquets HTTP et l’état React.
Localhost, petits jeux de données et pauses entre les clics peuvent masquer le défaut. En production, les utilisateurs continuent à saisir du texte et à naviguer pendant les requêtes. Limiter le débit aide au diagnostic, mais un test de régression fiable doit contrôler directement l’ordre des réponses.
Le problème existe aussi dans les gestionnaires d’événements, les loaders de routes, les hooks maison, les callbacks de mutation et les caches partagés. useEffect peut déclencher le travail ; il n’en est pas la cause fondamentale. Plusieurs opérations peuvent écrire le même état sans règle désignant celle qui reste pertinente.
Un code plausible, sans règle d’ordre
Le contrat de l’exemple est une liste résumée de légendes. Ces types appartiennent à l’API fictive de l’article ; pour assembler les extraits, les placer dans queueContract.ts. Le code vise React 19 et TypeScript 5.9, utilisés dans le dépôt.
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 };Voici useUnsafeQueue.ts, volontairement incorrect. Le chargement commence lors du choix d’une file, pas automatiquement au montage. Sa dépendance read sera l’adaptateur HTTP présenté plus loin.
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: 'Impossible de charger les légendes. Veuillez réessayer.' });
}
}
return { query, view, search };
}Chaque appel capture son propre next, mais tous peuvent remplacer view. Un succès tardif affiche les mauvaises lignes ; un échec tardif peut masquer une réussite plus récente. Ajouter une dépendance à un Effect ou envelopper la fonction dans useMemo ne décide pas quel résultat reste pertinent.
Accorder le droit d’écrire à la dernière intention
Sur cet écran, changer de file ou modifier le texte recherché remplace l’intention précédente. Chaque intention reçoit une identité distincte, seule autorisée à publier son résultat. Comparer uniquement les paramètres ne suffit pas : l’utilisateur peut choisir la file en attente, les archives, puis de nouveau la file en attente. Les première et troisième requêtes ont les mêmes paramètres, mais relèvent d’interactions différentes.
Ignorer un ancien résultat n’arrête pas le travail. Cela retire seulement à l’opération le droit de modifier cette vue. Cette protection reste utile avec un client non annulable ou une requête partagée dont un autre abonné au cache peut encore exploiter la réponse.
useCaptionQueue.ts combine cette règle avec l’annulation des lectures devenues inutiles. L’objet contrôleur sert aussi de marqueur local unique. commit() vérifie le droit d’écrire ; abort() demande séparément l’arrêt du travail. Le timer servira à l’autocomplétion ci-dessous.
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: 'Impossible de charger les légendes. Veuillez réessayer.' });
}
}
}
if (delay > 0) timer.current = setTimeout(() => { void run(); }, delay);
else void run();
}
return { query, view, search };
}Le propriétaire change de manière synchrone dès que search() reçoit une nouvelle intention, avant tout délai de debounce. Réussites et erreurs passent par le même contrôle. Le nettoyage au démontage retire le droit d’écrire, même si le client ignore l’annulation. Un indicateur isMounted ne distingue pas deux requêtes concurrentes dans un composant toujours monté.
Ce hook possède une vue pour un contexte d’archive et d’utilisateur donné. Ce n’est pas un cache de requêtes généraliste. Le client et le périmètre d’archive doivent rester cohérents pour cette instance ; changer d’archive ou de compte exige de l’invalider ou de démarrer explicitement un nouveau périmètre. L’objet de requête représente un instantané du choix, sans mutation par d’autres callbacks.
Annuler les lectures obsolètes sans masquer les vraies erreurs
L’annulation n’atteint fetch que si l’adaptateur transmet le signal. Voici captionApi.ts. L’endpoint fictif renvoie un tableau d’objets { id, label } avec des identifiants uniques. Ce petit contrôle à l’exécution vérifie les types et sélectionne les champs publics ; il ne remplace pas une validation complète de schéma.
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 }));
};Le composant peut recevoir readCaptionQueue. Cet adaptateur utilise un endpoint de même origine et le contrat de session existant. Il n’établit pas l’authentification et n’assouplit pas les contrôles d’accès.
Avec le comportement d’annulation par défaut du navigateur, la promesse est généralement rejetée avec AbortError. Une raison personnalisée ou un wrapper HTTP peut se comporter autrement. Le hook vérifie son propre signal, puisqu’il connaît la raison de l’annulation, plutôt que d’ignorer une catégorie de noms d’exceptions. Une vraie défaillance de la requête courante produit encore une erreur. Un timeout à signaler à l’utilisateur réclame sa propre politique, pas un traitement silencieux comme travail dépassé.
Annuler une requête dans le navigateur ne restaure pas l’état antérieur d’une transaction serveur. Une mutation peut déjà avoir été acceptée. Même pour une lecture, rien ne promet l’arrêt de chaque requête SQL ou intermédiaire. Les anciennes opérations doivent perdre leur droit de modifier l’UI, que l’annulation économise des ressources ou non.
Le debounce limite le travail, pas la validité des résultats
Le modérateur recherche « canal » : c, ca, can, cana, canal. Un résultat lent pour can ne doit pas remplacer celui de canal. Le debounce décide quand lancer un nouveau travail. Le contrôle de propriété décide quel résultat peut changer l’écran.
Un intervalle passe facilement inaperçu : can est déjà en cours, l’utilisateur saisit canal, puis l’ancienne réponse arrive pendant le nouveau délai. Invalider l’opération précédente seulement au prochain fetch serait trop tard. Notre hook le fait dès l’événement de saisie.
Ce fichier complet CaptionQueue.tsx lance immédiatement les requêtes des boutons et applique aux saisies un délai illustratif de 180 ms. Il retire volontairement les anciennes lignes pendant l’attente. Les conserver peut aussi être un choix produit, à condition de les présenter comme des résultats antérieurs, sans suggérer qu’ils correspondent au nouveau filtre.
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' })}>
En attente
</button>
<button type="button" onClick={() => search({ ...query, bucket: 'archived' })}>
Archives
</button>
<output aria-label="Liste sélectionnée">
{query.bucket === 'pending' ? 'En attente' : 'Archives'}
</output>
<input aria-label="Texte de la légende" value={query.term}
onChange={(event) => search({ ...query, term: event.target.value }, 180)} />
{view.phase === 'loading' && <p role="status">Chargement des légendes…</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>
);
}Le timer du hook programme le comportement réel ; les tests n’ont pas à attendre le temps réel. Une horloge contrôlée vérifie le debounce, des promesses contrôlées vérifient l’ordre d’achèvement. Mettre toutes les recherches à la suite de la plus ancienne éviterait leur chevauchement, mais ferait attendre chaque saisie derrière un résultat déjà inutile.
Données, chargement et erreurs doivent avoir le même propriétaire
A démarre, puis B. A se termine alors que B attend encore. Un finally { setLoading(false) } inconditionnel masquerait le chargement de B. Dans le hook corrigé, une ancienne opération ne peut plus écrire aucune phase : la vue reste loading jusqu’à la fin de sa propre opération.
Pour un tableau de bord chargeant trois panneaux indépendants, un compteur d’opérations ou un état distinct par panneau peut mieux convenir. Une règle globale « seule la dernière requête compte » éliminerait alors des résultats utiles.
Les erreurs subissent la même course. B peut réussir avant l’échec de A. Ce dernier ne doit pas remplacer des archives valides par un message d’erreur. Données, erreur et chargement forment un état d’opération cohérent. Les actualiser séparément sans vérifier leur propriétaire recrée le problème sous une forme plus discrète.
Navigation, lectures dépendantes et valeurs capturées
Une requête de la route précédente peut finir après la navigation. Le nettoyage au démontage protège l’état local de ce hook, mais un layout persistant ou un store partagé peut survivre à la page. Archive, entité sélectionnée, filtre et contexte utilisateur pertinent font partie de l’identité de la vue ou du cache. Invalider l’ancien périmètre lorsque la navigation en change le sens ; un changement de chemin ne signifie pas toujours un démontage.
Les requêtes dépendantes demandent la même discipline. Si sélectionner une photo déclenche d’abord ses métadonnées, puis ses légendes, toute la chaîne doit conserver l’identité de cette photo. Vérifier la pertinence avant la seconde lecture et avant la publication, et propager l’annulation lorsqu’elle est prise en charge.
Les callbacks voient les valeurs du rendu qui les a créés. Appeler setSelectedPhoto(next) puis loadCaptions(selectedPhoto.id) peut encore utiliser l’ancien identifiant. Passer explicitement next.id pour cette action. Une mise à jour fonctionnelle aide pour les calculs fondés sur l’état précédent ; elle ne détermine pas à quelle photo appartient une réponse serveur. Supprimer l’avertissement du linter sur les dépendances ne résout ni l’un ni l’autre.
Les mises à jour optimistes demandent un autre contrat
Un modérateur fait passer la priorité de vérification d’une légende de normale à haute, puis à urgente. L’UI applique immédiatement les deux modifications. Si la réponse pour haute arrive en dernier et remplace la ligne, l’affichage recule. Un rollback global après l’échec de la première mutation peut également effacer la seconde modification.
Plusieurs conceptions sont possibles : version de mutation par ressource, suivi des opérations en attente ou exécution séquentielle des modifications d’une même ressource. Un rollback doit viser son opération, sans restaurer tout un ancien instantané sur un état plus récent. Une bibliothèque de cache peut fournir une partie du mécanisme, mais sa sémantique de mutation doit correspondre au besoin.
Ignorer l’ancienne réponse protège l’affichage, pas l’état final du serveur. Relire immédiatement après la dernière mutation cliente ne suffit pas si une écriture antérieure peut encore être validée ensuite. Réconcilier l’état après la fin des écritures concernées, ou employer des versions serveur avec un protocole de conflit défini. Ne pas rejouer aveuglément une ancienne charge utile sur la modification acceptée d’un autre utilisateur.
Annulation, nouvelle tentative et opération métier sont distinctes
Désactiver le bouton réduit les doubles clics accidentels. Cela ne coordonne pas deux onglets, deux composants ou un autre utilisateur. Les doublons peuvent aussi provenir de retries explicites du client, d’un rechargement, de retries configurés dans un proxy ou d’une nouvelle livraison côté backend. Ce comportement dépend du système, pas d’une règle universelle des navigateurs.
Prenons POST /api/captions/28/approve. L’utilisateur change de page et le navigateur cesse d’attendre. L’approbation peut déjà être enregistrée et une notification programmée. Avant de rejouer une opération au résultat incertain, consulter son état si l’API le permet et respecter son contrat de reprise.
Distinguer trois identités. Le request ID corrèle une tentative de transport. L’identifiant d’intention de la vue désigne le choix qui possède actuellement l’écran. L’identifiant d’opération métier/clé d’idempotence représente une même approbation logique à travers plusieurs tentatives. Un nouveau marqueur de vue ne remplace pas une clé de reprise stable. Le backend doit imposer le périmètre de la clé, la cohérence de la charge utile et le traitement des doublons.
Une légende lue à la révision 12 peut être à la révision 13 au moment de l’enregistrement. Un contrôle de version attendue ou une écriture conditionnelle peut rejeter la modification dépassée ; le contrat d’API définit la réponse de conflit. Droits, unicité et changements atomiques en base restent des responsabilités serveur. L’annulation dans l’UI ne les remplace pas. Les détails de relivraison sont développés dans l’article GiSoft consacré à Messenger.
Choisir une source de vérité pour l’écran
Si l’URL possède le filtre, les contrôles locaux et la clé de requête doivent le refléter. Si le formulaire possède une modification non enregistrée, un rafraîchissement en arrière-plan ne doit pas l’écraser silencieusement. Le cache peut gérer les instantanés serveur, tandis que le backend reste l’autorité pour l’état métier validé. Dupliquer indépendamment les mêmes lignes dans Context, le composant et le cache complique la réconciliation.
Une bibliothèque de requêtes peut proposer déduplication, identité du cache, annulation et outils de mutation. Aucune ne répare une clé incomplète : résultats en attente et archivés ne doivent pas partager une entrée indifférenciée. Le dépôt ne déclare ni TanStack Query ni SWR ; les exemples n’en ajoutent donc pas. Lorsqu’une bibliothèque gère déjà les données, utiliser ses mécanismes prévus.
Avec Next.js App Router, coordonner filtres clients, paramètres de route et rafraîchissements serveur. Un rafraîchissement de route peut préserver l’état client non affecté et n’invalide pas à lui seul les caches serveur. Le comportement dépend du mode de routage, de la version et de la configuration. Ces extraits appartiennent à un sous-arbre client ; read vient du code client, pas d’une fonction ordinaire transmise à travers la frontière d’un Server Component.
Les Transitions concernent la priorité du rendu ; un travail asynchrone coordonné manuellement exige encore une règle d’ordre. Des Actions de plus haut niveau peuvent fournir leur propre sémantique. Envelopper un fetch quelconque dans startTransition ne fait pas automatiquement gagner la dernière action utilisateur.
Inverser les réponses dans un test déterministe
Il faut pouvoir choisir quand chaque résultat devient disponible. ReplyGate.ts est un helper de test élaboré pour ces exemples, pas une API Vitest ou React. Il permet de résoudre ou de rejeter une promesse :
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); }
}Le test emploie Vitest, React Testing Library, user-event et un environnement DOM, avec @testing-library/jest-dom/vitest chargé dans le setup. Il importe le composant et le contrat présentés plus haut. La doublure de l’adaptateur ignore volontairement l’annulation : le contrôle de propriété doit fonctionner même si le travail finit malgré tout.
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('conserve les archives malgré la réponse tardive de la file en attente', 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: 'En attente' }));
await user.click(screen.getByRole('button', { name: 'Archives' }));
expect(read).toHaveBeenCalledTimes(2);
expect(read.mock.calls[0][1]?.aborted).toBe(true);
await act(async () => {
archive.deliver([{ id: 'caption-28', label: 'Passerelle du canal' }]);
});
expect(screen.getByLabelText('Liste sélectionnée')).toHaveTextContent('Archives');
expect(screen.getByText('Passerelle du canal')).toBeVisible();
await act(async () => {
inbox.deliver([{ id: 'caption-11', label: 'Légende de l’arrêt de tram' }]);
});
expect(screen.getByText('Passerelle du canal')).toBeVisible();
expect(screen.queryByText('Légende de l’arrêt de tram')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});Les étapes d’observation comptent : établir d’abord que B est visible, puis livrer A et vérifier que B reste affiché. Contrôler uniquement le libellé final du filtre laisserait passer les mauvaises lignes. Ni latence réelle ni attente arbitraire ne décide de l’ordre.
Couvrir les autres courses au niveau fiable le moins coûteux
Les mêmes promesses permettent trois cas ciblés. Pour le chargement, livrer A pendant que B reste en attente : l’indicateur doit rester, puis disparaître après B. Pour l’erreur, livrer B puis rejeter A : B reste visible sans alerte. Pour l’annulation, faire rejeter la promesse de la doublure lors du signal abort : aucune erreur générique n’apparaît et B peut encore réussir. Tester aussi le nettoyage au démontage, sans dépendre d’anciens avertissements React.
Pour l’autocomplétion, avancer délibérément l’horloge de test : lancer can, saisir canal, résoudre can avant le nouveau timer et vérifier que ce résultat n’apparaît pas. Atteindre ensuite le délai configuré et livrer le résultat courant. Un cas supplémentaire attente → archives → attente protège contre la confusion entre paramètres identiques et intention identique.
Risque Frontière de test utile
Propriété / reducer Unitaire si logique extraite
Anciennes données, erreurs Composant React, chargement inclus
Signal et réponse HTTP Adaptateur / contrat
Écriture répétée Backend fonctionnel / intégration
Parcours réel de modération Navigateur, cas sélectionnéUn test unitaire convient à un coordinateur ou reducer pur ; inutile d’en extraire un pour gonfler le nombre de tests. Les tests d’adaptateur vérifient la transmission du signal, la validation, les codes d’erreur et les éventuels headers de version ou d’idempotence du contrat réel. Ils ne prouvent pas la déduplication backend. Celle-ci demande des tests serveur, avec concurrence si le risque l’exige.
Un test navigateur peut exercer la fonctionnalité réelle avec des réponses interceptées et contrôlées si l’outillage existant le permet. Réserver surtout aux composants les cas d’ordre précis. De larges tolérances temporelles et des pauses arbitraires en millisecondes remplacent mal le contrôle explicite des réponses. Les articles GiSoft Tester l’architecture, les défaillances et les parcours utilisateur avec Next.js et Comment tester les API Symfony avec Pest développent ces frontières plus larges.
Reconstruire la séquence avant de modifier le calendrier
Des métadonnées limitées doivent permettre de reconstruire intentions et fins d’opération : séquence locale, identifiant de corrélation, catégorie de requête sûre ou clé expurgée, périmètre de route, début, fin et décision d’appliquer ou d’ignorer le résultat. Ajouter au besoin une révision de réponse ou un identifiant métier. Ignorer un ancien résultat peut être le bon comportement, sans erreur serveur.
Texte recherché, descriptions de photos et données de compte peuvent être sensibles. Ne pas journaliser identifiants d’authentification, tokens, charges utiles complètes ou données personnelles inutiles pour étudier l’ordre. Les temps de requête du navigateur associés aux séquences locales expliquent souvent mieux le défaut qu’une copie intégrale des réponses.
Plusieurs corrections tentantes répondent à une autre question. Allonger le debounce réduit les appels mais laisse leur chevauchement possible. useMemo n’ordonne pas les promesses. isMounted distingue un cycle de vie, pas des intentions concurrentes. Un finally inconditionnel peut supprimer le chargement d’une autre requête. Désactiver un bouton aide l’utilisateur, sans fournir l’idempotence backend. Annuler chaque mutation peut faire perdre au client la connaissance d’un résultat déjà validé. Chaque outil a besoin d’une responsabilité précise.
Quelques vérifications avant livraison
- Nommer l’intention courante et l’état qu’elle est autorisée à remplacer.
- Retirer ce droit aux anciennes opérations dès le changement d’intention, debounce compris.
- Appliquer la même règle aux lignes, au chargement et aux erreurs ; annuler les lectures utiles à annuler.
- Définir la réconciliation backend des mutations incertaines, retries et éditions concurrentes.
- Tester les réponses inversées, l’échec tardif et les chargements superposés avec des résultats contrôlés.
La réponse qui finit en dernier ne reçoit aucun droit automatique sur l’écran. Ce droit doit être explicite, et les effets métier protégés séparément sur le serveur.
Références techniques
La fonctionnalité fictive et les exemples ont été élaborés pour cet article. Les détails des frameworks et de l’annulation ont été vérifiés dans ces documents :
- React, nettoyage des Effects : https://react.dev/reference/react/useEffect
- React, ordre des Transitions asynchrones : https://react.dev/reference/react/useTransition
- MDN, sémantique d’AbortController : https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort
- Next.js App Router, rafraîchissement : https://nextjs.org/docs/app/api-reference/functions/use-router
