Strona główna / Artykuły

Optymistyczne interfejsy użytkownika bez rozbieżności: zrzuty stanu, cofanie zmian i anulowane żądania

Naucz się tworzyć optymistyczne aktualizacje, które pozostają poprawne: zapisywanie stanu w momencie aktualizacji, natychmiastowa aktualizacja, cofanie zmian w przypadku błędu, anulowanie przestarzałych żądań oraz wiedza o sytuacjach, gdy nie należy stosować tego wzorca.

Wydawca: REACTAPP.TOP10 min czytania2290 słów

Źródła: Frontend Design Patterns: Optimistic Updates

Przy szybkiej łączności podróż tam i z powrotem za pomocą przycisku „polubić” jest niewidoczna. W pociągu poruszającym się między słabym LTE a ledwo użytecznym 3G ta sama podróż może trwać pięć lub dziesięć sekund, a w tym czasie interfejs pozostaje nieaktywny, podczas gdy użytkownik zastanawia się, czy kliknięcie w ogóle zostało zarejestrowane. Optymistyczne aktualizacje zamykają tę luki, najpierw zmieniając interfejs, a dopiero potem pytając serwer. Ten przewodnik pokazuje, jak zaimplementować ten wzorzec w trzech wyraźnych krokach, jak chronić go przed szybkimi żądaniami oraz jak określić, gdzie ma zastosowanie, a gdzie stanowi obciążenie.

Problem ukryty w jednym kliknięciu

Załóżmy post z 999 lajkami i sercem w konturze. W najbardziej rygorystycznym wariancie kliknięcie w serce wysyła żądanie do serwera, który może znajdować się na innym kontynencie. Serwer weryfikuje tożsamość użytkownika, zapisuje informacje do bazy danych i odpowiada – dopiero wtedy serce staje się czerwone, a licznik wzrasta do 1000. Taka chwila oczekiwania jest akceptowalna przy kilkuset milisekundach, ale uciążliwa przy pięciu sekundach. Ludzie, którzy nie widzą żadnej reakcji, klikają ponownie, potem po raz trzeci, a w końcu rezygnują z aplikacji – dlatego właśnie taka zwłoka jest tak skutecznym sposobem na utratę użytkowników.

Kliknięcie powoduje również większą ilość działań niż prosty przełącznik logiczny. Typowa platforma wykonywa na stronie serwerowej trzy czynności:

  • Zwraca się do punktu końcowego API, który zwiększa licznik lajków dla danego posta.
  • Dodaje post do zapisanej kolekcji na koncie użytkownika, aby można go było później znaleźć.
  • Wysyła sygnał do systemu rekomendacji, czyli części backendu odpowiedzialnej za wybór treści do pokazania dalej, dzięki czemu podobne treści pojawiają się częściej.
  • Wszystkie trzy elementy zależą od sieci. Z kolei w frontendzie wystarczy zmienić tylko dwa parametry stanu: to, czy serduszko jest wypełnione, oraz liczbę obok niego. Jeśli interfejs czeka na każdy efekt po stronie serwera przed zmianą tych wartości, aplikacja wygląda na niepracującą, mimo że funkcjonuje poprawnie.

    Optymizm jako celowe, naprawialne kłamstwo

    Optymistyczna aktualizacja rozwiązuje ten problem, polegając na tym, że frontend natychmiast zmienia swój własny stan, zakładając, że wysłany wcześniej żądanie zostanie przyjęte z powodzeniem. Gdy tylko palec opuszcza ekran, serduszko staje się czerwone, a liczba wynosi 1 000, mimo że nic jeszcze nie dotarło na serwer. To małe kłamstwo sprawia, że interakcja wydaje się natychmiastowa.

    Jest to do przyjęcia tylko wtedy, gdy istnieje plan na sytuację, gdy założenie się nie sprawdzi. Pociąg może wejść do tunelu i przerwać połączenie, albo serwer może zwrócić błąd. Pozostawienie wartości na poziomie 1000, podczas gdy baza danych wciąż pokazuje 999, powoduje rozbieżność: interfejs i źródło prawdy nie są zgodne. Frontend musi wtedy wrócić do rzeczywistego stanu – z sercem o określonym kształcie i wartością 999 – oraz zazwyczaj poinformować użytkownika za pomocą krótkiej wiadomości, która pojawia się na chwilę i sama znika, np. „Nie udało się to zapisać. Spróbuj ponownie.”

    Wdrożenie wzorca w trzech krokach

    Zmiana wartości zmiennej i nadzieja na poprawne wykonanie żądania to domysły, a nie element projektowania. Niezawodna optymistyczna aktualizacja dzieli zadania na trzy etapy: uchwycenie obecnego stanu, pokazanie oczekiwanego rezultatu oraz dostosowanie go do odpowiedzi serwera.

    Krok 1: utworzenie zrzutu stanu

    Zanim cokolwiek na ekranie się zmieni, zapisz wartości, które użytkownik może obecnie zobaczyć – czy post został polubiony oraz jego aktualną liczbę. Funkcja obsługująca otrzymuje post, funkcję do ustawiania stanu oraz funkcję do wyświetlania komunikatów, a także importuje żądanie API, które ostatecznie zostanie wysłane:

    // likeButton.js
    import { updateLikeOnServer } from "./api.js";
    
    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
      // act two goes here
      // act three goes here
    }
    

    Po tym kroku masz pojedynczy, nietknięty obiekt, który dokładnie opisuje to, co znajduje się na ekranie, gotowy do przywrócenia w przypadku problemów. Ważne jest skopiowanie tych dwóch pól do nowego obiektu: jeśli zachowalibyśmy odniesienie do obiektu zmiennego, który później ulegnie zmianie, „zdjęcie stanu” również by się zmieniło.

    Krok 2: zastosowanie oczekiwanego rezultatu

    Następnie zaktualizuj interfejs użytkownika bezpośrednio, jeszcze przed rozpoczęciem żądania sieciowego:

    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
    
      setPost({ liked: true, likeCount: post.likeCount + 1 });
      // act three goes here
    }
    

    Ekran teraz pokazuje to, o co prosił użytkownik – czerwone serduszko oraz wyższą liczbę, podczas gdy serwer nadal nic nie wie. Jedna rzecz do zwrócenia uwagi w własnym kodzie: w tym przykładzie przekazywany jest zupełnie nowy obiekt zawierający tylko pola liked i likeCount. Jeśli funkcja ustawiająca zastępuje całą wiadomość zamiast łączyć jej pola, należy rozszerzyć istniejącą wiadomość o nowe wartości, aby właściwości takie jak id nie zostały utracone.

    Krok 3: synchronizacja z serwerem

    Na koniec należy wysłać rzeczywisty żądanie i albo zachować stan optymistyczny, albo go usunąć. Żądanie znajduje się w bloku try; jeśli wystąpi błąd, mechanizm obsługi przywraca stan początkowy i wyświetla komunikat:

    async function handleLikeClick(post, setPost, showToast) {
      const previousState = { liked: post.liked, likeCount: post.likeCount };
      setPost({ liked: true, likeCount: post.likeCount + 1 });
      try {
        await updateLikeOnServer(post.id);
      } catch (error) {
        setPost(previousState);
        showToast("Couldn't save that. Try again.");
      }
    }
    

    Pomocnik API wykorzystuje funkcję fetch przeglądarki. Należy pamiętać, że fetch odrzuca żądanie jedynie w przypadku awarii sieci, a nie ze względu na błędy statusowe HTTP, dlatego pomocnik sprawdza wartość response.ok i wyraźnie rzuca błąd. Bez tego sprawdzenia odpowiedź 500 zostałaby uznana za sukces, a procedura odwracania zmian nigdy nie zostałaby uruchomiona:

    // api.js
    export async function updateLikeOnServer(postId) {
      const response = await fetch(`/api/posts/${postId}/like`, {
        method: "POST",
      });
    
      if (!response.ok) {
        throw new Error("Like request failed");
      }
      return response.json();
    }
    

    Po trzecim kroku istnieją tylko dwa możliwe wyniki. Albo żądanie zakończyło się sukcesem i stan optymistyczny stał się prawdziwy, albo zawiodło i interfejs powrócił do stanu z pierwszego kroku. W obu przypadkach interfejs nigdy nie pokazuje niczego, czego serwer nie może obsłużyć przez dłuższy czas niż ten potrzebny na awarię żądania.

    Gdzie optymistyczne aktualizacje przynoszą korzyści

    Dodatkowa struktura jest opłacalna tylko wtedy, gdy procedura odwracania zmian jest prosta, a postrzegana szybkość ma duże znaczenie. Trzy kategorie spójnie odpowiadają temu profilowi:

    1. Interakcje społeczne. Lajki, obserwowanie i reakcje nie wiążą się z dużym ryzykiem. Jeśli jeden z wielu kliknięć dziennie nie powiedzie się, kosztem jest konieczność ponownego kliknięcia przez użytkownika.
    2. Komunikacja tekstowa. W aplikacjach do czatowania, takich jak WhatsApp czy Slack, wiadomość wysłana przy słabej łączności pojawia się natychmiast w rozmowie, często z przygaszonym kolorem lub małym szarym ikoną zegara. Jest to optymistyczne aktualizowanie z widocznym znacznikiem oczekiwania: wiadomość pojawia się na ekranie, zanim dotrze do serwera.
    3. Tablice typu „przeciągnij i upuść”. Przeciąganie karty do kolumny „Zakończone” w tablicy kanban, takiej jak Jira czy Trello, byłoby trudne, jakby przeciągało się przez mokry piasek, gdyby karta musiała czekać na potwierdzenie. W optymistycznym wariancie karta natychmiast trafia na miejsce, a w przypadku niepowodzenia wraca do swojej pierwotnej kolumny.

    Korzystną ulepszeniem w porównaniu do standardowych komunikatów jest wskaźnik zaległych operacji. Pokazanie, że dana akcja nie została jeszcze potwierdzona, pozwala działać szybko i uczciwie jednocześnie, co zmniejsza zaskoczenie w przypadku odwołania transakcji.

    Gdzie optymizm staje się niebezpieczny

    Załóżmy teraz aplikację bankową. Mieszkaniec naciska „Wyślij”, aby przelać pięć tysięcy dolarów czynszu właścicielowi. Dzięki optymistycznemu komunikatowi na ekranie natychmiast pojawia się informacja o pomyślnym przekazaniu środków i zerowym saldzie, więc użytkownik zamyka aplikację, sądząc, że czynsz został zapłacony.

    Za kulisami prośba zostaje odrzucona; nie ma znaczenia, czy to z powodu niewystarczających środków, czy odrzuconej transakcji. Użytkownik nadal wierzy, że pieniądze zostały przelane, dopóki właściciel nie zadzwoni i nie zapyta o czynsz. To przynajmniej oznacza konieczność otworzenia zgłoszenia obsługi klienta oraz uszkodzenie zaufania, a dzieje się tak dlatego, że schemat przeznaczony do tanich, odwracalnych działań został zastosowany w sytuacji, gdzie błąd ma rzeczywiste konsekwencje.

    Ta sama ostrożność dotyczy kilku powiązanych kategorii:

    1. Transakcje finansowe. Należy pokazać ikonkę spinującą się kółka. Czekanie sygnalizuje, że system rzeczywiście sprawdził operację przed jej potwierdzeniem.
    2. Nieodwracalne usuwania. Gdy ktoś usunie swoje konto, ekran nie powinien się wyłączyć, dopóki serwer nie potwierdzi, że dane zniknęły.
  • Zmiany w zakresie bezpieczeństwa. Aktualizacje haseł oraz ustawienia dwufaktorowe wymagają pewności, a nie interfejsu twierdzącego z góry o sukcesie.
  • Kluczowym kryterium nie jest to, na jak istotną czynność wygląda dana akcja, lecz ile kosztuje błąd w jej ocenie. Lepiej kazać komuś czekać trzy sekundy dłużej na animację, niż informować go, że pieniądze zostały przelane, podczas gdy tak w rzeczywistości nie było.

    Gdy dwie akcje następują jedna po drugiej

    Istnieje drugi scenariusz awarii, który pojawia się tylko wtedy, gdy użytkownik podejmuje kolejną akcję przed zakończeniem pierwszej prośby. Wracając do powolnego pociągu, użytkownik polubi post, przesuwając jego pozycję z 999 na 1 000, a następnie natychmiast go niepolubić, przywracając pozycję na 999. Załóżmy, że polubienie zajmuje pięć sekund, podczas gdy niepolubienie tylko dwie; wtedy niepolubienie może dotrzeć pierwsze.

    Serwer następnie przetwarza akcję „nie polubiłem” dla wpisu, który jeszcze nie został uznany za polubiony, więc nic się nie dzieje, a po kilku sekundach przetwarza akcję „polubiłem”. Na serwerze wpis zostaje oznaczony jako polubiony, podczas gdy na ekranie pokazuje się coś przeciwnego do tego, co użytkownik ostatnio zrobił. Gdy ostateczny stan jest określany na podstawie kolejności przybycia zapytań, a nie kolejności działań użytkownika, mamy do czynienia z warunkiem wyścigu.

    Anulowanie przestarzałych żądań za pomocą AbortController

    Zwykłym rozwiązaniem jest anulowanie: gdy rozpoczyna się nowsze żądanie tego samego zasobu, należy poinstruować przeglądarkę, aby porzuciła starsze. Wbudowany AbortController może anulować żądanie fetch, które jest już w trakcie przetwarzania. Przykład ten przechowuje odniesienie do aktywnego kontrolera, anuluje go przed rozpoczęciem nowego żądania i wybiera POST lub DELETE w zależności od pożądanego stanu:

    // likeButton.js
    let activeRequest = null;
    
    async function toggleLike(postId, shouldLike) {
      if (activeRequest) {
        activeRequest.abort();
      }
      const controller = new AbortController();
      activeRequest = controller;
      await fetch(`/api/posts/${postId}/like`, {
        method: shouldLike ? "POST" : "DELETE",
        signal: controller.signal,
      });
    }
    

    Każde nowe naciśnięcie anuluje poprzednią prośbę, więc ma zostać zrealizowana tylko najnowsza akcja. Przed poleganiem wyłącznie na tym warto znać dwie kwestie:

    • Anulowanie powstrzymuje przeglądarkę przed czekaniem na odpowiedź; nie gwarantuje to, że serwer w ogóle nie otrzymał ani nie przetworzył żądania. Aby uzyskać silniejsze gwarancje, należy zaprojektować punkt końcowy wokół pożądanego stanu końcowego (polubione lub nie polubione), tak aby powtarzane lub przetasowane żądania doprowadzały do tego samego wyniku, albo dodać numer sekwencyjny, który serwer może wykorzystać do ignorowania przestarzałych zapisów.
    • Anulowane żądanie fetch zwraca błąd AbortError. Jeśli połączysz to z procedurą cofnięcia z trzeciego kroku, sprawdź ten błąd i pomiń procedurę cofnięcia – w przeciwnym razie anulowanie przestarzałego żądania błędnie cofnęłoby nowszy, optymistyczny stan.

    W aplikacjach React biblioteki do obsługi danych zajmują się większością tych zadań. TanStack Query, który zarządza buforowaniem stanu serwera oraz cyklami życia żądań, oraz Redux Toolkit, oficjalny zestaw narzędzi do pisania logiki Redux, oba zapewniają wbudowaną obsługę aktualizacji optymistycznych i możliwość ich anulowania, dzięki czemu rzadko trzeba ręcznie konfigurować AbortController. Sprawdź ich dokumentację, aby dowiedzieć się dokładnie, co zostaje anulowane w każdym przypadku; zrozumienie mechanizmu działania pozostaje przydatne. Aby lepiej zrozumieć, jak mogą się nie udawać procedury cofania zmian przy użyciu wbudowanego hooka w React, przeczytaj pięć sposobów awarii przy użyciu optymistycznych procedur cofania.

    Główne wnioski

    • Podróż sieciowa może być na tyle wolna, że sprawi, iż działająca aplikacja będzie wyglądać na uszkodzoną, dlatego aktualizacje optymistyczne najpierw pokazują oczekiwany wynik, a potem go potwierdzają.
  • Każdą optymistyczną aktualizację strukturyzuj jako zrzut obrazu, zastosowanie i synchronizację; to właśnie zrzut obrazu sprawia, że odwrócenie zmian jest proste.
  • Zrób błędy widocznymi: funkcja fetch nie odrzuca żądań przy błędach HTTP, więc rzuć wyjątek w przypadku !response.ok, a także poinformuj użytkownika za pomocą komunikatu, gdy dokonasz odwrócenia zmian.
  • Wykorzystaj ten wzorzec do tanich, odwracalnych działań takich jak lajki, wiadomości i ruchy na tablicy; pokaż wirtualny wirnik podczas procesów płatności, usuwania i zmian bezpieczeństwa.
  • Ochronij się przed konfliktami poprzez anulowanie przestarzałych żądań, ignorowanie błędów AbortError w logice odwracania zmian oraz preferowanie punktów końcowych, które ustalają ostateczny stan zamiast go zmieniać.
  • Szybkość to funkcja, której sieć nie da ci za darmo. Interfejs optymistyczny akceptuje fakt, że nie może kontrolować połączenia, mimo to odpowiada użytkownikowi z pewnością siebie i pozostaje gotowy, by cofnąć tę odpowiedź w momencie, gdy okaże się błędna.

    Powiązane materiały

  • Projektowanie ekranów React dla stanów ładowania, pustki, błędu i ponawiania — Dowiedz się, jak modelować każdy możliwy stan ekranu i formularza w React, od struktur początkowych i ponownych pobierania danych po puste wyniki, przydatne błędy oraz ochronę przed podwójnym wysyłaniem danych.
  • Klucze React jako identyfikator komponentu: zachowywanie i resetowanie stanu — Dowiedz się, dlaczego klucze React decydują o tym, która instancja komponentu przeżyje proces renderowania, jak klucze indeksowe powodują wyciek stanu między wierszami oraz jak używać klucza do resetowania formularza.
  • Anulowanie przestarzałych zadań w React: AbortController do zmiany torów — Dowiedz się, dlaczego pominięte tory i przestarzałe zapytania nadal wpływają na interfejs użytkownika, jak AbortController anuluje rzeczywisty żądanie oraz w jaki sposób uzupełnia funkcje debounce i throttle w React.