useOptimistic в React: типичные ошибки
Хук useOptimistic в React — про быстрый UI. Пользователь жмёт «лайк», UI меняется мгновенно, а сервер догоняет в фоне. Идея простая, реализация в коде на одну строку, но за полгода работы я насобирала пять-шесть граблей, которые повторяются у каждой команды.
В этом тексте не введение в API — оно есть в документации, — а разбор реальных ошибок и того, как их обходить.
Базовый шаблон
Чтобы было от чего отталкиваться, минимальный пример:
'use client';
import { useOptimistic } from 'react';
import { addLike } from './actions';
export function Likes({ postId, count }: { postId: string; count: number }) {
const [optimistic, setOptimistic] = useOptimistic(count, (state, delta: number) => state + delta);
return (
<form action={async () => {
setOptimistic(1);
await addLike(postId);
}}>
<button type="submit">❤️ {optimistic}</button>
</form>
);
}Поведение: сразу после клика optimistic увеличивается. Если addLike завершается успешно и страница ревалидируется, count приходит уже с сервера. Если падает — optimistic сам откатывается к исходному count.
Ошибка 1. Не ревалидируем после действия
Самая частая. Action завершился, на сервере данные новые, но компонент остался с тем же count. После короткой задержки оптимистичное значение откатывается, и UI возвращается к исходным цифрам — даже если действие было успешным.
Лечится одним вызовом в server action:
// actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function addLike(postId: string) {
await db.like.create({ data: { postId } });
revalidatePath(`/p/${postId}`);
}Без revalidatePath или revalidateTag родитель не получит обновлённые данные, и хук решит, что действия не было. Я раз пять видел этот баг в чужих PR — обычно его ловят только во время ручного тестирования.
Ошибка 2. Передаём не делту, а абсолютное значение
В простых случаях кажется удобнее писать так:
setOptimistic(count + 1);Это рабочий код, но он ломается при двойном клике. Если пользователь жмёт лайк дважды быстро, оба раза будет count + 1, и оптимистичный счётчик не дойдёт до count + 2.
Правильно — передавать дельту в редьюсер:
const [optimistic, setOptimistic] = useOptimistic(count, (state, delta: number) => state + delta);
// в обработчике:
setOptimistic(1);
setOptimistic(1); // даст +2 от текущегоВ редьюсере state — это всегда последнее накопленное значение. Хук сам обрабатывает очередь.
Ошибка 3. Завязка на исходное значение из локального стейта
Видела в чужом коде:
const [count, setCount] = useState(initialCount);
const [optimistic, setOptimistic] = useOptimistic(count, ...);
Кажется естественным, но useOptimistic рассчитывает на то, что базовое значение приходит «извне» и обновляется при ревалидации (через сервер или родителя). Если ты внутрь подкладываешь useState, ревалидация не работает: setCount вручную ты вызвать забываешь, и оптимистик откатывается в старое.
Правильно: count — это пропс, который приходит сверху. Если хочешь добавить локальные интеракции — комбинируй с useTransition и нормальным state, но без скрещивания с useOptimistic.
Ошибка 4. Игнорирование ошибок
Когда action падает, useOptimistic откатывает значение. Но это тихий откат: пользователь видит, что цифра «отжалась», но не понимает почему. Если действие было важным (отправка комментария, удаление поста) — нужен явный feedback.
async function onSubmit() {
setOptimistic(1);
try {
await addLike(postId);
} catch (e) {
toast.error('Не удалось поставить лайк');
}
}Не клади всё в action и забудь. Catch-блок и toast — обязательные.
Ошибка 5. Стейт сложного объекта
Если редьюсер работает не с числом, а с объектом или массивом — копируй данные, не мутируй. Вот так делать нельзя:
const [optimistic, setOptimistic] = useOptimistic(comments, (state, newComment) => {
state.push(newComment); // мутация — ломает React
return state;
});А вот так — да:
const [optimistic, setOptimistic] = useOptimistic(comments, (state, newComment) => [
...state,
newComment,
]);React сравнивает по ссылке. Если ты вернул тот же массив — он решит, что ничего не изменилось, и не перерисует.
Ошибка 6. setOptimistic снаружи action
Хук работает только внутри transition (action в форме или startTransition). Если попытаешься вызвать setOptimistic в обычном обработчике без обёртки — React ругнётся в консоли и не применит изменение.
// плохо
<button onClick={() => { setOptimistic(1); addLike(postId); }}>Like</button>
// хорошо
import { useTransition } from 'react';
const [pending, startTransition] = useTransition();
<button onClick={() => startTransition(async () => {
setOptimistic(1);
await addLike(postId);
})}>Like</button>На формах с action-prop transition включается автоматически. Поэтому форма — самый безопасный путь.
Когда useOptimistic не нужен
Не каждое действие должно быть оптимистичным. Если у тебя:
- Финансовая операция (платёж, перевод) — пользователю важно увидеть подтверждение, а не «казалось бы».
- Действие, у которого высокая вероятность провала (в зависимости от внешних факторов).
- Действие, требующее серверной валидации, без которой UI отрисовать невозможно (например, проверка кода 2FA).
В этих случаях лучше показать loading и подождать честный ответ. UX от этого не пострадает — наоборот, будет понятнее.
Сочетание с TanStack Query
Если у тебя на клиенте уже работает TanStack Query — у него собственная система оптимистичных обновлений через onMutate/onError. Они хорошо стыкуются с серверными actions, и часто получаются проще, чем useOptimistic, особенно когда нужна сложная инвалидация.
Я использую useOptimistic ровно там, где у меня нет клиентского кеша и где state одного маленького компонента, не нужно тащить целую библиотеку. Для всего остального — Query.
Что копать дальше
Главное правило: useOptimistic ускоряет UI, но не заменяет ревалидацию и обработку ошибок. Тестируй сценарии «всё хорошо», «упало», «упало с задержкой», «двойной клик». Если в каждом из них поведение разумное — паттерн работает. Если в каком-то — нет, скорее всего где-то одна из шести ошибок выше.