useOptimistic w React 19 — UI, który reaguje zanim serwer odpowie

useOptimistic hook w React 19 — optimistic updates bez boilerplate

useOptimistic React 19 to ostatni z trzech hooków, o których piszę w tej serii. W pierwszym wpisie pokazywałam jak useActionState eliminuje ręczne zarządzanie pending state i błędami. W drugim — jak useFormStatus udostępnia stan formularza zagnieżdżonym komponentom bez props drillingu.

Tym razem zajmiemy się czymś trochę innym — nie tym jak obsłużyć stan formularza, ale jak sprawić żeby UI reagowało natychmiast, jeszcze zanim serwer w ogóle odpowie. 🚀

😬 Problem — UI czeka na serwer

Wyobraźcie sobie listę zadań (todo list). Użytkownik klika “Dodaj” — i czeka. Przycisk jest zablokowany, lista się nie zmienia, spinner kręci się przez 300ms, 500ms, może sekundę. A potem pojawia się nowy element.

To działa poprawnie. Ale nie czuje się dobrze. Użytkownik nie wie czy kliknięcie zadziałało, czy może trzeba kliknąć jeszcze raz. 🤷

Klasyczne rozwiązanie to optimistic update — aktualizujemy UI natychmiast, zakładając że operacja się powiedzie. Jeśli coś pójdzie nie tak — cofamy zmianę. Użytkownik widzi efekt swojego działania od razu, bez czekania na serwer.

W React 18 trzeba to było robić ręcznie. Zobaczcie jak to wyglądało:

function TodoList({ todos }: { todos: Todo[] }) {
  const [localTodos, setLocalTodos] = useState(todos);
  const [isPending, setIsPending] = useState(false);

  async function addTodo(text: string) {
    // Tymczasowo dodaj element z fałszywym ID
    const tempTodo = { id: 'temp-' + Date.now(), text, done: false };
    setLocalTodos(prev => [...prev, tempTodo]);
    setIsPending(true);

    try {
      const savedTodo = await saveTodo(text);
      // Zastąp tymczasowy element prawdziwym
      setLocalTodos(prev =>
        prev.map(t => t.id === tempTodo.id ? savedTodo : t)
      );
    } catch {
      // Cofnij zmianę przy błędzie
      setLocalTodos(prev => prev.filter(t => t.id !== tempTodo.id));
    } finally {
      setIsPending(false);
    }
  }

  // ...
}

Tymczasowe ID, ręczne mapowanie żeby zastąpić temp prawdziwym elementem, ręczne cofanie przy błędzie. Każdy nowy typ akcji to kolejna porcja tego samego boilerplate’u. 📈

✨ Rozwiązanie — useOptimistic

import { useOptimistic, useActionState } from 'react';

type Todo = { id: string; text: string; done: boolean };

function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos, newText: string) => [
      ...currentTodos,
      { id: 'temp', text: newText, done: false },
    ]
  );

  const [, action] = useActionState(
    async (_: unknown, formData: FormData) => {
      const text = formData.get('text') as string;
      addOptimisticTodo(text);
      await saveTodo(text);
    },
    null
  );

  return (
    <>
      <ul>
        {optimisticTodos.map(todo => (
          <li
            key={todo.id}
            style={{ opacity: todo.id === 'temp' ? 0.5 : 1 }}
          >
            {todo.text}
          </li>
        ))}
      </ul>
      <form action={action}>
        <input name="text" placeholder="Nowe zadanie" />
        <button type="submit">Dodaj</button>
      </form>
    </>
  );
}

Przejdźmy przez to krok po kroku. 👇

Sygnatura hooka

const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
  • state — prawdziwy stan, który dostajecie z zewnątrz (np. z serwera)
  • updateFn — funkcja która opisuje jak ma wyglądać stan optymistyczny; przyjmuje aktualny stan i wartość przekazaną do addOptimistic, zwraca nowy stan
  • optimisticState — stan który renderujecie; podczas akcji zawiera wersję optymistyczną, po zakończeniu wraca do prawdziwego stanu
  • addOptimistic — funkcja którą wołacie żeby wyzwolić optimistic update

Jak to działa pod spodem

React zarządza dwoma wersjami stanu jednocześnie — prawdziwym i optymistycznym. Kiedy wołacie addOptimisticoptimisticState natychmiast przyjmuje wartość zwróconą przez updateFn. Gdy akcja się zakończy (sukcesem lub błędem), React automatycznie cofa optimistic update i wraca do prawdziwego stanu — który w przypadku sukcesu powinien już zawierać nowy element z serwera. 🔄

opacity: 0.5 — mały detal, duże znaczenie

Zauważcie ten fragment:

style={{ opacity: todo.id === 'temp' ? 0.5 : 1 }}

To prosty sygnał dla użytkownika że element jest jeszcze w trakcie zapisywania. Widzi go natychmiast, ale lekko wyszarzony — wie że coś się dzieje. Gdy serwer odpowie i temp zostanie zastąpiony prawdziwym elementem, opacity wraca do 1. Mały detal, ale robi dużą różnicę w odczuciu responsywności. 🎯

🔄 Automatyczne cofanie przy błędzie

To jest moje ulubione w useOptimistic — nie musicie sami obsługiwać rollbacku. 😊

Jeśli akcja rzuci błąd, React automatycznie cofa optimistic update i przywraca poprzedni stan. Użytkownik zobaczy że element zniknął — co jest sygnałem że coś poszło nie tak. Możecie to połączyć z błędem z useActionState:

const [state, action] = useActionState(
  async (_: { error: string | null }, formData: FormData) => {
    const text = formData.get('text') as string;

    if (!text.trim()) return { error: 'Zadanie nie może być puste.' };

    addOptimisticTodo(text);

    try {
      await saveTodo(text);
      return { error: null };
    } catch {
      // useOptimistic automatycznie cofa zmianę
      // wystarczy zwrócić błąd do wyświetlenia
      return { error: 'Nie udało się zapisać zadania.' };
    }
  },
  { error: null }
);

💡 Bardziej złożony przykład — edycja elementu

useOptimistic nie ogranicza się do dodawania elementów. Działa równie dobrze przy edycji czy usuwaniu:

type Comment = { id: string; text: string; likes: number };

function CommentList({ comments }: { comments: Comment[] }) {
  const [optimisticComments, updateOptimisticComment] = useOptimistic(
    comments,
    (current, { id, likes }: { id: string; likes: number }) =>
      current.map(c => c.id === id ? { ...c, likes } : c)
  );

  async function likeComment(id: string, currentLikes: number) {
    // Natychmiast pokaż +1
    updateOptimisticComment({ id, likes: currentLikes + 1 });
    await addLike(id);
    // Po zakończeniu akcji React zastąpi optymistyczny stan prawdziwym
  }

  return (
    <ul>
      {optimisticComments.map(comment => (
        <li key={comment.id}>
          {comment.text}
          <button onClick={() => likeComment(comment.id, comment.likes)}>
            ❤️ {comment.likes}
          </button>
        </li>
      ))}
    </ul>
  );
}

Kliknięcie “serce” natychmiast zwiększa licznik — bez czekania na serwer. Jeśli żądanie się nie powiedzie, licznik wraca do poprzedniej wartości. 🎯

⚠️ Na co uważać

useOptimistic działa tylko podczas trwania akcji 🔄

Optimistic update jest aktywny tylko kiedy trwa jakaś asynchroniczna akcja. Poza tym optimisticState jest identyczny z prawdziwym stanem. Nie możecie używać go do trwałych lokalnych zmian.

Wołajcie addOptimistic wewnątrz akcji 📌

// ✅ wewnątrz akcji — działa
const [, action] = useActionState(async (_, formData) => {
  addOptimisticTodo(text);
  await saveTodo(text);
}, null);

// ❌ poza akcją — optimistic update cofnie się natychmiast
function handleClick() {
  addOptimisticTodo(text); // cofnie się od razu, nie ma sensu
}

Prawdziwy stan musi się zaktualizować po akcji 🔁

useOptimistic cofa optimistic update i wraca do prawdziwego stanu. Jeśli po udanej akcji prawdziwy stan nie zostanie zaktualizowany (np. nie zrobicie rewalidacji danych), UI pokaże stary stan bez nowego elementu.

Import z react, nie z react-dom 👀

// ✅
import { useOptimistic } from 'react';

🤷 Kiedy useOptimistic nie jest potrzebny?

Nie każda akcja wymaga optimistic update. Mnie osobiście sprawdza się prosta zasada — jeśli użytkownik widzi efekt swojej akcji w UI i musi na niego poczekać, warto rozważyć optimistic update. Jeśli akcja dzieje się “w tle” i użytkownik i tak by nie czekał — useOptimistic to overkill.

Konkretnie: warto go używać przy:

  • ➕ dodawaniu elementów do listy
  • ✏️ edycji elementów w miejscu
  • ❤️ przyciskach like/reakcjach
  • 🗑️ usuwaniu elementów

Nie warto przy:

  • 📧 wysyłaniu emaili / formularzy kontaktowych
  • 💳 płatnościach i innych krytycznych operacjach
  • 🔐 akcjach z poważnymi konsekwencjami (nie chcecie pokazywać sukcesu zanim będziecie pewni)

📋 Podsumowanie serii

To ostatni wpis z serii o formularzach w React 19. Zobaczcie razem jak te trzy hooki się uzupełniają:

HookCo robi
⚙️ useActionStateZarządza stanem akcji — pending, błędy, wynik
🌳 useFormStatusUdostępnia pending state zagnieżdżonym komponentom
⚡ useOptimisticAktualizuje UI natychmiast, przed odpowiedzią serwera

Razem dają Wam kompletny zestaw narzędzi do obsługi formularzy w React 19 — bez zewnętrznych bibliotek, bez boilerplate’u, z pełną kontrolą nad UX. 💪


Używacie optimistic updates w swoich projektach? Ciekawa jestem jakie macie przypadki gdzie to szczególnie pomaga — i czy zdarzyło Wam się trafić na edge case gdzie rollback nie zadziałał tak jak się spodziewaliście. 👇

You might also like