Strona główna / Artykuły

Rozwiązywanie problemów warunków wyścigowych – technika debouncing nie może pomóc w interfejsach wyszukiwania

Dowiedz się, dlaczego sama technika debouncing nie może zapobiec przywracaniu starych odpowiedzi API, które mogą nadpisać aktualny stan interfejsu, oraz poznaj cztery praktyczne rozwiązania umożliwiające zapewnienie właściwej kolejności zapytań.

Wydawca: REACTAPP.TOP10 min czytania2397 słów

Źródła: I Thought Debouncing Fixed My Search Bar.

Rok temu dodałem opóźnienie o 300 milisekund do pola wyszukiwania w jednym z projektów i uznałem, że problem został rozwiązany.

Później jeden z testerów zgłosił coś dziwnego: jeśli pisał szybko, wyniki na chwilę pokazywały coś niepowiązanego, zanim wracały do prawidłowego stanu.

Pierwotna reakcja była niedowierzaniem. Sprawdzenie karty sieci pokazało, że mechanizm opóźnienia działa dokładnie tak, jak powinien — jedna prośba na każdą przerwę, bez dodatkowego hałasu.

Błąd jednak rzeczywiście istniał. Po prostu to nie był ten błąd, który został naprawiony.

Każdy, kto budował pole wyszukiwania, zapewne miał do czynienia z taką sytuacją. Każde naciśnięcie klawisza wywołuje żądanie do sieci, co ostatecznie przytłacza serwer.

Zwykłą radą jest „po prostu dodać opóźnienie”. Dodaje się krótkie opóźnienie, ilość żądań spada i sprawa zostaje zamknięta.

Małe decyzje takie jak ta gromadzą się w sposób, który łatwo jest niedoceniać. Ta konkretna decyzja wydaje się błahe, ale może spowodować większe szkody, niż się wydaje.

Oto co mechanizm debounce tak naprawdę nie gwarantuje: kontroluje kiedy wysyłany jest żądanie. Nie ma wpływu na to, która odpowiedź dotrze pierwsza.

Czym właściwie zajmuje się mechanizm debounce w pólach wyszukiwania

Mechanizm debounce w polu wyszukiwania rozwiązuje dokładnie jeden problem: nadmiar wysyłanych żądań.

To jest całe zakres jego działania. Kolejność żądań pozostaje nienaruszona.

Oto dokładnie, jak może dojść do błędu. Załóżmy okno debounce wynoszące 300 ms.

  1. Wpisujesz rea, a następnie przestajesz pisać na 300 ms. Wysyłane jest żądanie A dla rea.
  2. Kontynuujesz pisanie: react. Po kolejnej przerwie wysyłane jest żądanie B dla react.
  • Zapytanie A omija pamięć cache serwera i potrzebuje 900 ms na rozwiązanie.
  • Zapytanie B trafia do pamięci cache i zwraca wyniki w ciągu 120 ms.
  • B wraca pierwszy, więc interfejs wyświetla wyniki dla react.
  • Następnie, po 700 ms, przychodzi A i zastępuje wyniki na ekranie wynikami dla rea.
  • Pole wejściowe zawiera react, ale lista poniżej pokazuje wyniki dla rea. To niezgodność jest tym migotaniem, które zauważył tester.

    Debouncing zmniejsza częstotliwość występowania tego zjawiska, ale nie eliminuje jego możliwości. A „rzadziej” to dokładnie ten typ błędu, który pozostaje niewidoczny podczas rozwoju, gdy API odpowiada natychmiastowo i testuje go tylko jedna osoba.

    Nie jest jasne, czy zachowanie to jest identyczne we wszystkich kombinacjach frameworków i narzędzi.

    W projektach React wykorzystujących fetch lub axios ten wzorzec pojawia się konsekwentnie, za każdym razem.

    To nie jest tylko problem z paskiem wyszukiwania

    Gdy już rozpoznasz ten sposób awarii, zaczniesz go dostrzegać w wielu innych miejscach. To ten sam podstawowy błąd, który przybiera różne formy:

    • Spadające listy filtrów. Użytkownik wybiera „Buty”, a następnie szybko przechodzi na „Torby”. Jeśli żądanie dotyczące butów zostanie zrealizowane jako ostatnie, strona wyświetli listę butów pod etykietą „Torby”.
    • Karty. Ktoś kliknie „Zamówienia”, a potem natychmiast „Faktury”. Jeśli odpowiedź dotycząca zamówień przyjdzie wolniej, i tak wygra konkurencję, pozostawiając niewłaściwe dane pod właściwą kartą.
  • Paginacja. Szybkie kilkukrotne kliknięcie przycisku „Następny” może spowodować, że odpowiedź od strony 2 przyjdzie po odpowiedzi od strony 4.
  • Wybieracze dat na panelach administracyjnych. Dwukrotne szybkie zmiany zakresu dat może sprawić, że wykres będzie pokazywał dane z zastąpionego zakresu.
  • Zawsze, gdy użytkownik może szybciej zmienić swoje zamiary niż serwer może na to zareagować, pojawia się ta sama sytuacja konkurencyjna.

    Rzeczywiste aplikacje mogą jeszcze bardziej utrudnić rozwiązanie tego problemu.

    Wyobraźmy sobie panel administracyjny, w którym ktoś wybiera:

    • Ostatnie 30 dni
    • Następnie ostatnie 7 dni
    • Następnie dzisiaj

    W tym momencie trzy oddzielne żądania mogą być wysyłane jednocześnie.

    Nawet dokładne dane stają się błędnymi, gdy odpowiadają działaniu, które użytkownik już zakończył.

    Dlaczego dłuższe opóźnienie nie pomaga

    Pierwszym odruchem jest zwykle zwiększenie okna debounce – spróbuj 500 ms, a potem może 800 ms.

    To dostosowanie, dokonane głównie z przyzwyczajenia a nie na podstawie racjonalnych rozważań, w ogóle nie zmniejszyło częstotliwości występowania migotania. Tylko sprawiło, że interfejs wydawał się wolny w działaniu. Zapytanie, które nie znajduje danych w pamięci cache i trwa 900 ms, nadal będzie działać dłużej niż okno debounce o długości 800 ms.

    Dostosowywanie wartości czasowych nie może rozwiązać problemu leżącego u podstaw sekwencjonowania.

    To właśnie w tym momencie staje się jasna prawdziwa natura błędu.

    Debounce to mechanizm kontroli ruchu.

    Sytuacja wyścigowa to błąd sekwencjonowania.

    Podwyższenie czasu, przez jaki sygnalizacja pozostaje czerwona, nie decyduje o tym, który samochód dotrze do celu pierwszy.

    Rozwiązanie tego problemu wymaga zupełnie innego zasady.

    Prawdziwe rozwiązanie: Tylko najnowszy żądanie może aktualizować interfejs użytkownika

    To jedno zdanie stanowi całe rozwiązanie. Istnieje kilka technik, które je wprowadzają, od prostych po bardziej zaawansowane.

    Rozwiązanie 1: Przypisać każdemu żądaniu numer biletu

    To najprostsza wersja, którą warto wyjaśnić pierwszą, ponieważ ujawnia podstawowe mechanizmy działania.

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

    Każdemu wysyłanemu żądaniu przypisywany jest numer biletu.

    Gdy przychodzi odpowiedź, system pyta siebie: „Czy nadal posiadam najnowszy bilet?”

    Jeśli odpowiedź brzmi „nie”, odpowiedź jest odrzucana. Same dane nie są błędne — po prostu są przestarzałe. A przestarzałe dane wyglądają dokładnie tak samo jak błędne, gdy są wyświetlane na ekranie.

    Rozwiązanie 2: Anulować stare żądanie za pomocą AbortController

    Sprawdzanie numerów ticketów po prostu ignoruje przestarzałe odpowiedzi po ich otrzymaniu. AbortController zapobiega ich otrzymaniu od samego początku. Oto idea w prostym JavaScript, bez użycia żadnego frameworka:

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

    Jeden szczegół, który łatwo przeoczyć: wywołanie fetch, które zostaje przerwane, odmawia obsługi z błędem AbortError. Jeśli pominiesz obsługę tego błędu, na każdym naciśnięciu klawisza będziesz rejestrował fałszywy błąd.

    Należy również pamiętać, że przerwanie tylko powstrzymuje przeglądarkę przed oczekiwaniem na odpowiedź — serwer może mimo to kontynuować przetwarzanie żądania. Jest to nieszkodliwe w przypadku funkcji wyszukiwania, ale staje się istotne, gdy żądanie polega na zapisywaniu danych.

    Rozwiązanie 3: Sposób React — czyszczenie za pomocą useEffect

    Gdy logika wyszukiwania znajduje się wewnątrz efektu, React dostarcza już wbudowany mechanizm dla dokładnie takiego scenariusza: funkcję cleanup. To właśnie taki podejście jest zalecane bezpośrednio w dokumentacji Reacta.

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

    Zauważ, co ten wzorzec automatycznie zapewnia. Deobserwuje się samą wartość, a nie funkcję, która wysyła żądanie.

    To oznacza, że za każdym razem, gdy zmienia się deobserwowana wartość, najpierw uruchamiana jest funkcja cleanup z poprzedniej iteracji, co anuluje to przestarzałe żądanie. Odłączenie komponentu również wyzwaluje taką samą funkcję cleanup. Nie potrzeba do tego refów ani ręcznie zwiększanych liczników.

    Rozwiązanie 4: Pozwól bibliotece do pobierania danych się tym zająć

    Jeśli twój projekt już korzysta z TanStack Query (React Query), ta gwarancja kolejności działa bezpłatnie — nie wymaga to żadnej specjalistycznej logiki.

    Gdy tylko klucz zapytania ulega zmianie, poprzednie zapytanie staje się przestarzałe, a biblioteka automatycznie przekazuje twojej funkcji pobierania signal, który ją dla ciebie anuluje.

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

    Prawdziwa praca odbywa się w dwóch miejscach: signal przekazywany do queryFn oraz opcja placeholderData: keepPreviousData, która utrzymuje poprzednią listę widoczną, zamiast pozostawiać ekran pusty podczas ładowania kolejnej partii wyników.

    SWR oferuje takie samo zachowanie dzięki własnemu ustawieniu keepPreviousData.

    Małe nawyki, które znacznie ułatwiły prawidłowe działanie

    Poniżej wymieniono nawyki, które warto włączyć do każdej implementacji wyszukiwania. Żaden z nich nie jest szczególnie genialny, ale każdy w pewnym momencie zapobiegł powstaniu prawdziwego błędu.

    1. Zawsze koduj ciąg zapytania. Błądząca przestrzeń lub znak & wewnątrz terminu wyszukiwania może zepsuć Twoją URL. Za każdym razem, gdy tworzysz żądanie, umieść wartość w funkcji encodeURIComponent(query). Pole wyszukiwania w środowisku produkcyjnym może naprawdę zawieść na tak prostym wpisie jak „R&D”.

    2. Ignoruj puste lub zbyt krótkie zapytania. Usuń spacje z wpisu i pomijaj wysyłanie żądania dla pustej strony tekstu. Ustalenie minimalnej długości, na przykład dwóch znaków, zmniejsza liczbę niepotrzebnych żądań i ogranicza ryzyko konfliktów.

    3. Kontynuuj wyświetlanie poprzednich wyników podczas ładowania nowego zapytania. Unikaj usuwania listy przy każdym naciśnięciu klawisza. Mały element spinający obok pola wprowadzania jest o wiele mniej irytujący niż pusta ekranika zmieniająca się między poszukiwaniami — nieco przestarzała lista jest lepsza niż nic w ogóle. To dokładnie takie zachowanie zapewnia funkcja keepPreviousData.

    4. Zachowuj w pamięci tymczasowej wyniki, które już pobrano. Ludzie często usuwają znaki i wpisują je ponownie. Prosty obiekt Map z kluczem w postaci terminu wyszukiwania gwarantuje, że wpisanie najpierw rea, potem react, a następnie cofnięcie do rea ponownie nie powoduje dwukrotnego wysłania zapytania do serwera.

    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. Niech serwer backendowy wysyła zapytanie razem z odpowiedzią. Zamiast zwracać zwykły tablicę, należy odpowiedzieć czymś w rodzaju { query: "react", results: [...] }. Dzięki temu klient może przeprowadzić dodatkową weryfikację — if (data.query !== currentQuery) return; — co stanowi tanie dodatkowe zabezpieczenie i sprawia, że każdy błąd jest natychmiast widoczny w karcie sieci.

    6. Celowo odtworz warunki wyścigu. Prawdopodobnie nie zauważysz tego błędu na localhost przy API, które odpowiada w pięciu milisekundach. Wprowadź sztuczne, losowe opóźnienie do swojego serwera mock i problem pojawi się niemal natychmiast:

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

    Alternatywnie otwórz Chrome DevTools, przejdź do zakładki Network, włącz throttling, wybierz „Slow 3G” i wpisywaj tekst szybko. Zobaczysz, że system zawiedzie.

    7. Użyj klawisza Enter, aby ominąć opóźnienie debounce. Gdy użytkownik naciśnie Enter, należy natychmiast uruchomić wyszukiwanie — opóźnienia debounce są przeznaczone do sytuacji ciągłego pisania, a nie do wyraźnego sygnału intencji.

    8. Zawsze anuluj operację przy odłączaniu komponentu. Jeśli użytkownik odejdzie, podczas gdy trwa wyszukiwanie, ostateczna odpowiedź spróbuje wywołać setState w komponencie, który już nie istnieje. Funkcja czyszczenia pokazana w Poprawce 3 już obejmuje ten przypadek. Jeśli zamiast tego używasz metody numeru ticket, upewnij się, że funkcja czyszczenia przy odłączaniu również zwiększa licznik.

    9. Użyj useDeferredValue podczas filtrowania lokalnego, a nie przez sieć. Jeśli filtrujesz listę, która jest już załadowana do pamięci, w ogóle nie potrzebujesz mechanizmów opóźniania lub anulowania żądań. useDeferredValue zapewnia płynne doświadczenie podczas pisania, podczas gdy React zajmuje się filtrowaniem w tle — ale nie zastępuje logiki anulowania, gdy zaangażowane jest żądanie do serwera.

    10. Napisz jeden test, który bezpośrednio sprawdza warunek wyścigu. Symuluj dwa żądania typu fetch i rozwiąż drugie przed pierwszym. Sprawdź, czy interfejs pokazuje wynik pochodzący z drugiego żądania. To krótki test, ale pozwoli wykryć regresję, która w przeciwnym razie mogłaby umknąć podczas przeglądania kodu.

    Jeśli twoja pola wyszukiwania kiedykolwiek pokazywały przez chwilę błędne wyniki, zanim się poprawiły, to przyczyną nie jest opóźnienie debouncingu, a dodanie większego opóźnienia również tego nie naprawi.

    Zastosowanie mechanizmu debouncingu do wprowadzania zapytań wyszukiwawczych wciąż jest dobrą praktyką — znacząco zmniejsza liczbę wysyłanych zapytań. Po prostu sam w sobie nie rozwiązuje całego problemu.

    Potrzebna jest również wyraźna zasada kolejności: tylko najnowsze zapytanie może aktualizować interfejs użytkownika. Możesz to zapewnić za pomocą ID zapytania, AbortController, funkcji czyszczenia typu useEffect lub biblioteki, która już implementuje ten mechanizm. Każde z tych rozwiązań działa — kluczowe jest wybranie jednego i konsekwentne jego stosowanie.

    Literatura pokrewna

  • Natywne API browserów zastępujące popularne pakiety npm w 2026 roku — Wyjaśnia, w jaki sposób natywne funkcje JavaScript i CSS, takie jak Signals, operator pipeline, Temporal oraz Anchor Positioning, zastępują powszechnie używane pakiety npm.