Optimistic UI w React: interfejs, który świadomie kłamie
Optimistic UI w React: interfejs, który świadomie kłamie
W artykule o mikrointerakcjach pisałem o progu Doherty'ego – jeśli system odpowiada w mniej niż 400 ms, użytkownik postrzega go jako natychmiastowy. Mikrointerakcje domykają tę lukę animacją stanu przycisku, zanim odpowiedź serwera wróci. Optimistic UI idzie o krok dalej: nie tylko animuje oczekiwanie, tylko zakłada z góry, że akcja się powiedzie, i od razu pokazuje efekt końcowy – serce w przycisku „Polub” wypełnia się natychmiast, licznik rośnie o jeden, mimo że request do serwera dopiero wystartował. Interfejs mówi użytkownikowi coś, co jeszcze nie jest prawdą – i zakłada, że za chwilę nią będzie.
To działa doskonale, dopóki założenie jest trafne – a w praktyce, dla akcji typu „polub”, „dodaj do ulubionych” czy „zaznacz jako przeczytane”, jest trafne w zdecydowanej większości przypadków. Problem zaczyna się tam, gdzie większość wdrożeń próbuje to zrobić ręcznie: setState na kliknięcie, catch z odwrotnym setState przy błędzie. Wygląda niewinnie w izolowanym teście z jednym kliknięciem. Rozjeżdża się, gdy użytkownik kliknie przycisk dwa razy szybko pod rząd – dokładnie ten sam czwarty element modelu Saffera (pętle i tryby) z artykułu o mikrointerakcjach, tyle że tym razem błąd nie jest kosmetyczny, tylko prowadzi do realnie niespójnego stanu.
Dlaczego ręczny rollback się rozjeżdża przy dwóch kliknięciach
Wyobraź sobie najbardziej oczywistą, ręczną implementację: kliknięcie od razu przełącza liked w stanie lokalnym, wysyła request, a w catch cofa liked do wartości sprzed kliknięcia, którą zapamiętano w zmiennej przed wysłaniem requestu.
jsx
// Naiwny rollback – działa dla jednego kliknięcia, psuje się przy dwóchconst handleClick = async () => {const previousLiked = liked; // zamrożona migawka stanu SPRZED kliknięciasetLiked(!liked);try {await toggleLike(postId, !liked);} catch {setLiked(previousLiked); // cofa do stanu sprzed TEGO kliknięcia}};
Scenariusz, który to psuje: użytkownik klika „polub” (start requestu A, previousLiked = false), po 100 ms klika „odlub” (start requestu B, previousLiked = true, bo stan lokalny już zdążył się zmienić na true). Jeśli request A zawiedzie, a request B akurat trwa albo już się powiódł, catch requestu A cofnie stan do false – nadpisując efekt kliknięcia B, które użytkownik wykonał świadomie i które mogło się już powieść. Rollback jednego requestu skasował skutek drugiego, bo oba operowały na tej samej, współdzielonej zmiennej stanu, bez żadnej wiedzy o sobie nawzajem. To dokładnie ten typ błędu, który nie wychodzi w szybkim teście manualnym – wychodzi na produkcji, gdy realny użytkownik kliknie szybciej, niż deweloper to sobie wyobrażał podczas pisania kodu.
Jak useOptimistic unika tego problemu strukturalnie, nie łatką
useOptimistic w Reakcie nie jest wygodniejszym opakowaniem na setState plus try/catch. To inny model koncepcyjny: zamiast trzymać jedno, mutowalne pole stanu, które ręcznie przełączasz i ręcznie cofasz, useOptimistic oblicza tymczasową nakładkę na bieżąco, na podstawie aktualnego prawdziwego stanu, widoczną tylko na czas trwania danej operacji (transition). Gdy operacja się zakończy – sukcesem albo błędem – nakładka znika sama, odsłaniając to, co w danym momencie jest prawdziwym stanem. Nie piszesz kodu rollbacku. Rollback to naturalna konsekwencja zniknięcia tymczasowej nakładki, a nie osobna ścieżka kodu, którą możesz zapomnieć obsłużyć albo obsłużyć źle.
jsx
"use client";import { useOptimistic, useTransition } from "react";import { toggleLikeAction } from "./actions";const LikeButton = ({ postId, liked, likeCount }) => {const [isPending, startTransition] = useTransition();const [optimistic, setOptimistic] = useOptimistic({ liked, likeCount },(state, nextLiked) => ({liked: nextLiked,likeCount: state.likeCount + (nextLiked ? 1 : -1),}),);const handleClick = () => {const nextLiked = !optimistic.liked;startTransition(async () => {setOptimistic(nextLiked);await toggleLikeAction(postId, nextLiked);// Bez ręcznego rollbacku w catch – jeśli akcja rzuci błąd,// React i tak odrzuci nakładkę po zakończeniu transition,// odsłaniając rzeczywisty "liked" przekazany w propsach.});};return (<buttontype="button"onClick={handleClick}aria-pressed={optimistic.liked}className={isPending ? "like-button--pending" : ""}><span aria-hidden="true">{optimistic.liked ? "♥" : "♡"}</span>{optimistic.likeCount}</button>);};export default LikeButton;
Kluczowa różnica względem ręcznej wersji: nakładka obliczana przez useOptimistic bazuje na state przekazanym jako pierwszy argument – czyli na aktualnym, prawdziwym stanie z propsów (zwykle pochodzącym z Server Action i revalidatePath, jak w artykule o Server Actions) – a nie na osobnej, zamrożonej migawce zapamiętanej ręcznie w zmiennej lokalnej. Gdy użytkownik klika dwa razy pod rząd, każde wywołanie setOptimistic liczy nową nakładkę względem bieżącego stanu, więc drugie kliknięcie nie może zostać przypadkowo nadpisane przez spóźniony rollback pierwszego – nie istnieje osobna ścieżka „cofnij do zapamiętanej wartości”, która mogłaby się pomylić, o który dokładnie moment w czasie chodziło.
Kiedy optimistic UI to dobry pomysł, a kiedy jest niebezpieczne kłamstwo
Nie każda akcja nadaje się do tego wzorca, i to nie jest kwestia gustu, tylko konsekwencji błędnego założenia. „Polub”, „dodaj do ulubionych”, „oznacz jako przeczytane” – tanie w skutkach, łatwo odwracalne, prawie zawsze się powodzą. Płatność, nieodwracalne usunięcie konta, wysłanie wiadomości do drugiej osoby – tu optymistyczne pokazanie sukcesu, zanim faktycznie do niego doszło, jest ryzykowne: użytkownik podejmuje kolejne decyzje na podstawie informacji, która może okazać się fałszywa, a cofnięcie takiego „kłamstwa” kilka sekund później jest znacznie bardziej dezorientujące niż zwykły spinner, który po prostu trwa trochę dłużej.
Nawet tam, gdzie optimistic UI ma sens, cichy rollback bez żadnej informacji odtwarza dokładnie ten problem percepcyjny, który miał rozwiązywać. Użytkownik zobaczył serce wypełnione, uznał sprawę za zamkniętą, przeniósł uwagę gdzie indziej – a chwilę później serce po cichu wraca do pustego stanu, bez wyjaśnienia. To ten sam błąd „braku pętli i trybów” z modelu Saffera, tylko przesunięty w czasie: feedback na sukces był, ale feedback na porażkę już nie. Rozwiązanie jest tym samym narzędziem, które opisywałem w artykule o dostępności – aria-live="polite" albo role="alert" przy komunikacie o niepowodzeniu, żeby cofnięcie stanu nie było ciche wizualnie ani dźwiękowo dla nikogo, kto akurat nie patrzy dokładnie na ten przycisk w momencie, w którym się cofa.
Pułapki i dobre praktyki
setOptimisticmusi być wywołane wewnątrz transition. PozastartTransition(albo action przekazaną douseActionState/<form action>)useOptimisticnie zadziała zgodnie z oczekiwaniami – mechanizm jest nierozerwalnie związany z cyklem życia konkretnej transition, nie z dowolnym momentem renderu.- Funkcja aktualizująca przekazana do
useOptimisticmusi być czysta i tania. Wykonuje się synchronicznie podczas renderu, żeby obliczyć nakładkę – złożone obliczenia czy efekty uboczne w tym miejscu to błąd kategorii, nie tylko kwestia wydajności. - Nie ukrywaj porażki w milczeniu. Cichy rollback bez komunikatu jest gorszy niż brak optymistycznej aktualizacji w ogóle – użytkownik dostaje fałszywe potwierdzenie, a potem żadnej informacji, że coś jednak poszło nie tak.
- Rezerwuj wzorzec dla akcji tanich i odwracalnych. Tam, gdzie koszt błędnego założenia jest wysoki (płatności, nieodwracalne operacje), lepszym wyborem jest zwykły stan ładowania z jasnym potwierdzeniem po fakcie, nie interfejs, który zgaduje z góry.
Podsumowanie
Optimistic UI nie jest sztuczką animacyjną – to świadome założenie, że akcja się powiedzie, pokazane użytkownikowi zanim serwer to potwierdzi. Ręczna implementacja przez setState i catch działa dopóty, dopóki ktoś nie kliknie szybciej niż deweloper to przewidział – wtedy rollback jednej operacji potrafi nadpisać skutek drugiej, bo obie dzielą tę samą, zamrożoną migawkę stanu. useOptimistic usuwa tę klasę błędów strukturalnie: nakładka jest zawsze liczona względem aktualnego, prawdziwego stanu, a nie względem osobno zapamiętanej wartości, więc rollback nie jest kodem, który piszesz i możesz napisać źle – jest naturalną konsekwencją zniknięcia tymczasowego założenia. Zostaje jedna decyzja, której żadne API nie podejmie za Ciebie: czy dana akcja jest wystarczająco tania i odwracalna, żeby w ogóle było sensowne kłamać na jej temat, choćby przez chwilę.