Accueil / Articles

Résoudre les conditions de concurrence : le débouncing ne suffit pas dans les interfaces de recherche

Découvrez pourquoi le débouncing seul ne peut pas empêcher que des réponses API obsolètes écrasent l’état de l’interface utilisateur mis à jour, et explorez quatre solutions pratiques pour garantir le respect de l’ordre des requêtes.

Éditeur : REACTAPP.TOP10 min de lecture2397 mots

Sources: I Thought Debouncing Fixed My Search Bar.

L’an dernier, j’ai ajouté un délai de filtrage de 300 millisecondes à une zone de recherche dans un projet et j’ai considéré le problème résolu.

Plus tard, un testeur a signalé quelque chose d’étrange : si ils tapaient rapidement, les résultats affichaient brièvement des éléments sans rapport avant de se stabiliser correctement.

La réaction initiale a été le scepticisme. Une inspection de l’onglet réseau montrait que le mécanisme de filtrage fonctionnait exactement comme prévu — une seule requête par pause, sans bruit supplémentaire.

Cependant, le bug était bien réel. Il ne s’agissait simplement pas du bug qui avait été corrigé.

Tout ceux qui ont conçu une barre de recherche ont probablement déjà rencontré ce problème. Chaque frappe déclenche une requête réseau, ce qui surcharge le système backend.

Le conseil habituel est de « simplement ajouter un délai de filtrage ». On introduit une courte pause, le volume des requêtes diminue et le ticket est clos.

De petites décisions comme celle-ci s’accumulent de manières que l’on a tendance à sous-estimer. Cette décision en particulier semble mineure, mais elle peut causer plus de dommages qu’il n’y paraît.

C’est ce que le mécanisme de debounce ne garantit jamais vraiment : il contrôle quand une requête est envoyée. Il n’a aucun impact sur quelle réponse arrive en premier.

Ce que le debounce des barres de recherche résout réellement

Le debounce d’une barre de recherche cible précisément un seul problème : un excès de requêtes envoyées.

C’est l’ensemble de son fonctionnement. L’ordre des réponses n’est pas affecté.

Voici exactement comment cela peut mal se passer. Supposons une fenêtre de debounce de 300 ms.

  1. Vous tapez rea, puis vous arrêtez de taper pendant 300 ms. La requête A est envoyée pour rea.
  2. Vous continuez à taper : react. Après une autre pause, la requête B est envoyée pour react.
  • La demande A ne trouve pas dans le cache du serveur et met 900 ms pour être traitée.
  • La demande B trouve le cache et renvoie les résultats en 120 ms.
  • B arrive en premier, ce qui fait que l’interface affiche les résultats pour react.
  • Puis, 700 ms plus tard, A arrive et remplace l’écran par les résultats pour rea.
  • Le champ de saisie indique react, mais la liste ci-dessous affiche des résultats pour rea. Cette incohérence est celle que le testeur a remarquée.

    Le débouncing réduit la fréquence à laquelle cela se produit, mais il n’élimine pas cette possibilité. Or, « moins souvent » est précisément le type de défaut qui reste caché pendant le développement, lorsque l’API répond instantanément et qu’une seule personne effectue les tests.

    Il n’est pas clair si ce comportement est identique pour toutes les combinaisons de frameworks et d’outils.

    Dans les projets React utilisant fetch ou axios, ce schéma apparaît cependant de manière constante, à chaque fois.

    Ce n’est pas seulement un problème de barre de recherche

    Dès que vous reconnaissez ce mode d’échec, vous commencez à le remarquer dans de nombreux autres endroits. Il s’agit du même bug fondamental sous des formes différentes :

    • Champs de filtrage déroulants. Un utilisateur sélectionne « Chaussures », puis passe rapidement à « Sacs ». Si la requête concernant les chaussures est traitée en dernier, la page affiche finalement des listes de chaussures sous l’étiquette « Sacs ».
    • Onglets. Quelqu’un clique sur « Commandes », puis immédiatement sur « Factures ». Si la réponse concernant les commandes est plus lente, elle gagne malgré tout, ce qui fait apparaître les mauvaises données sous l’onglet correct.
  • Pagination. Cliquer rapidement plusieurs fois de suite sur « Suivant » peut faire en sorte que la réponse de la page 2 arrive après celle de la page 4.
  • Sélecteurs de dates sur les tableaux de bord. Changer deux fois la plage de dates rapidement peut faire en sorte qu’un graphique affiche encore des données correspondant à la plage précédente.
  • Dès qu’un utilisateur peut modifier son intention plus vite que le système backend ne peut répondre, cette même condition de course est présente.

    Les applications réelles peuvent rendre cette situation encore plus complexe à résoudre.

    Imaginez un tableau de bord d’administrateur où quelqu’un sélectionne :

    • Les 30 derniers jours
    • Puis les 7 derniers jours
    • Puis aujourd’hui

    À ce moment-là, trois requêtes distinctes pourraient être en cours simultanément.

    Même des données précises deviennent erronées dès qu’elles correspondent à une action que l’utilisateur a déjà terminée.

    Pourquoi un délai plus long ne sert à rien

    L’instinct premier est généralement d’augmenter la fenêtre de debounce — essayer 500 ms, puis peut-être 800 ms.

    Cette modification, motivée principalement par l’habitude plutôt que par une réflexion sérieuse, n’a eu aucun effet sur la fréquence des clignotements. Elle a simplement rendu l’interface plus lente. Une requête qui ne trouve pas dans le cache et met 900 ms à être traitée prendra toujours plus de temps qu’une fenêtre de debounce de 800 ms.

    Ajuster une valeur temporelle ne peut pas résoudre un problème lié à la séquenciation.

    C’est à ce moment-là que la véritable nature du bug devient évidente.

    Un debounce est un mécanisme de régulation du trafic.

    Une condition de course est une faille de séquenciation.

    Prolonger le temps pendant lequel un feu de signalisation reste rouge ne détermine pas quel véhicule arrivera en premier à la destination.

    Résoudre ce problème nécessite une règle complètement différente.

    La vraie solution : seule la dernière requête est autorisée à mettre à jour l’interface utilisateur

    Ce seul paragraphe constitue toute la solution. Plusieurs techniques permettent de l’appliquer, allant des solutions simples aux approches plus complètes.

    Solution 1 : attribuer un numéro de ticket à chaque requête

    C’est la version la plus facile à expliquer en premier, car elle rend les mécanismes sous-jacents évidents.

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

    Chaque requête envoyée reçoit un numéro de ticket.

    Lorsqu’une réponse revient, elle se demande : « Possède-t‑on encore le ticket le plus récent ? »

    Si la réponse est non, la réponse est rejetée. Les données elles‑mêmes ne sont pas incorrectes — elles sont simplement obsolètes. Or, les données obsolètes ont exactement le même aspect que des données fausses une fois affichées à l’écran.

    Solution 2 : annuler la vieille requête avec AbortController

    Vérifier les numéros de ticket ignore simplement les réponses obsolètes une fois qu’elles sont arrivées. AbortController empêche même leur arrivée. Voici l’idée en JavaScript pur, sans aucun framework :

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

    Un détail facile à négliger : une requête fetch qui est interrompue rejette l’appel avec une erreur AbortError. Si vous omettez de gérer cela, vous finirez par enregistrer une erreur fausse à chaque frappe.

    Il convient également de se rappeler qu’une interruption ne fait que stopper le navigateur d’attendre la réponse — le serveur peut tout de même continuer à traiter la requête. Cela est inoffensif pour une fonction de recherche, mais cela devient plus problématique lorsque la requête implique l’écriture de données.

    Réparation 3 : La méthode React — nettoyage avec useEffect

    Lorsque votre logique de recherche se trouve à l’intérieur d’un effet, React propose déjà un mécanisme intégré pour ce scénario précis : la fonction de nettoyage. C’est l’approche recommandée directement dans la documentation officielle de React.

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

    Remarquez ce que ce pattern vous offre automatiquement. Vous débouchez la valeur elle-même, et non la fonction qui envoie la requête.

    Cela signifie que chaque fois que la valeur débouchée change, la fonction de nettoyage issue de l’exécution précédente s’exécute en premier, annulant ainsi cette requête obsolète. Le démontage du composant déclenche également ce même nettoyage. Vous n’avez pas besoin de refs ni de compteurs incrémentés manuellement pour que cela fonctionne.

    Réparation 4 : Laissez une bibliothèque de récupération de données s’en occuper

    Si votre projet dépend déjà de TanStack Query (React Query), cette garantie d’ordre est offerte gratuitement — aucune logique personnalisée n’est requise.

    Lorsque la clé de requête change, la requête précédente devient obsolète, et la bibliothèque transmet automatiquement à votre fonction de récupération un signal qui l’abandonne pour vous.

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

    Le travail réel a lieu en deux endroits : le signal transmis à queryFn, et l’option placeholderData: keepPreviousData, qui maintient la liste précédente visible au lieu de laisser l’écran vide pendant le chargement du prochain lot de résultats.

    SWR offre le même comportement grâce à sa propre configuration keepPreviousData.

    Petits habitudes qui ont grandement facilité la bonne exécution

    Voici des habitudes qui méritent d’être intégrées dans toute implémentation de recherche. Aucune n’est particulièrement ingénieuse, mais chacune a permis d’éviter un vrai bug à un moment donné.

    1. Encodagez toujours la chaîne de requête. Un espace superflu ou un caractère & à l’intérieur du terme de recherche peut endommager votre URL. Enveloppez la valeur dans encodeURIComponent(query) chaque fois que vous créez une requête. Une boîte de recherche en environnement de production peut véritablement échouer avec une entrée aussi simple que "R&D">.

    2. Ignorez les requêtes vides ou trop courtes. Supprimez les espaces en trop de l’entrée et évitez d’envoyer une requête pour une chaîne vide. En définissant une longueur minimale, par exemple deux caractères, vous réduisez le nombre de requêtes inutiles et diminuez les risques de conflits.

    3. Continuez d’afficher les résultats précédents pendant le chargement d’une nouvelle requête. Évitez de vider la liste à chaque frappe. Un petit indicateur en rotation à côté du champ de saisie est bien moins perturbant qu’un écran vide qui s’affiche et disparaît à chaque recherche — une liste légèrement obsolète vaut mieux que rien du tout. C’est précisément le comportement assuré par keepPreviousData.

    4. Cachez les résultats que vous avez déjà récupérés. Les utilisateurs effacent fréquemment des caractères pour les rétypez. Un simple Map indexé par le terme de recherche garantit que taper rea, puis react, puis effacer pour revenir à rea ne sollicite pas deux fois le serveur pour la même requête.

    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. Faire en sorte que le backend renvoie la requête avec la réponse. Au lieu de retourner un simple tableau, répondre par quelque chose comme { query: "react", results: [...] }. Cela permet au client d’effectuer une vérification supplémentaire — if (data.query !== currentQuery) return; — qui sert de mesure de sécurité secondaire peu coûteuse et rend tout bug immédiatement visible dans l’onglet Réseau.

    6. Recréer délibérément la condition de course. Il est peu probable que vous remarquiez ce bug en local contre une API qui répond en cinq millisecondes. Introduisez un délai aléatoire artificiel dans votre serveur de simulation et le problème apparaîtra presque immédiatement :

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

    Alternativement, ouvrez les Outils de développement de Chrome, allez dans Réseau, activez le ralenti, choisissez « Slow 3G » et tapez rapidement. Vous verrez que cela échoue.

    7. Utilisez la touche Entrée pour contourner le délai de debounce. Lorsqu’un utilisateur appuie sur Entrée, lancez immédiatement la recherche — les délais de debounce sont conçus pour une frappe continue, et non pour un signal explicite d’intention.

    8. Annulez toujours la recherche en cas de désinstallation du composant. Si un utilisateur quitte l’application pendant qu’une recherche est en cours, la réponse finale tentera d’appeler setState sur un composant qui n’existe plus. La fonction de nettoyage présentée dans la Correction 3 couvre déjà ce cas. Si vous utilisez plutôt l’approche basée sur le numéro de ticket, assurez-vous que la fonction de nettoyage lors de la désinstallation incrémente également le compteur.

    9. Préférez useDeferredValue lors du filtrage en local, et non via le réseau. Si vous filtrez une liste déjà chargée en mémoire, vous n’avez absolument pas besoin de débouncing ni d’annulation de requête. useDeferredValue assure une expérience de saisie fluide tandis que React gère le filtrage en arrière-plan — mais il ne remplace pas la logique d’annulation lorsque des requêtes serveur sont impliquées.

    10. Écrivez un seul test ciblant directement la condition de course. Simulez deux appels fetch et résolvez le second avant le premier. Vérifiez que l’interface affiche bien le résultat provenant du deuxième appel. Il s’agit d’un test court, mais il permettra de détecter une régression qui passerait probablement inaperçue lors d’une revue de code normale.

    Si votre barre de recherche a déjà affiché temporairement des résultats incorrects avant de se corriger, le délai de debounce n’en est pas la cause, et ajouter plus de délai ne résoudra pas non plus le problème.

    Le debounce pour les entrées de recherche reste une bonne pratique — il réduit significativement le nombre de requêtes envoyées. Il ne résout cependant pas tout le problème à lui seul.

    Vous avez également besoin d’une règle de priorisation explicite : seule la requête la plus récente est autorisée à mettre à jour l’interface utilisateur. Vous pouvez en assurer le respect grâce à un ID de requête, à un AbortController, à une fonction de nettoyage useEffect, ou à une bibliothèque qui met déjà en œuvre ce principe pour vous. Toutes ces approches fonctionnent — l’essentiel est de choisir une d’entre elles et de l’utiliser de manière cohérente.

    Lectures complémentaires