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ą doaddOptimistic, zwraca nowy stanoptimisticState— stan który renderujecie; podczas akcji zawiera wersję optymistyczną, po zakończeniu wraca do prawdziwego stanuaddOptimistic— 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 addOptimistic, optimisticState 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ą:
| Hook | Co robi |
|---|---|
⚙️ useActionState | Zarządza stanem akcji — pending, błędy, wynik |
🌳 useFormStatus | Udostępnia pending state zagnieżdżonym komponentom |
⚡ useOptimistic | Aktualizuje 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. 👇



