Разбираем useOptimistic в React 19: как мгновенно обновлять интерфейс, сочетать с useActionState и избегать типичных ошибок. Практические примеры внутри.
Вы когда-нибудь нажимали «лайк», и интерфейс обновлялся с задержкой? Или комментарий появлялся только после ответа сервера? В этой статье вы научитесь делать мгновенные обновления с помощью useOptimistic — хука React 19, который избавляет от ручного отката состояния. Разберем реальные ошибки и их решения.
В первой части мы разобрали useActionState для ожидания ответа сервера. Но некоторые действия не должны ждать: лайки, комментарии, чекбоксы. Пользователь хочет видеть результат мгновенно, а не после сетевого запроса. Старый подход — оптимистичное обновление вручную:
function handleLike() {
setLiked(true); // обновляем сразу
fetch('/api/like', { method: 'POST' })
.catch(() => setLiked(false)); // откат при ошибке
}
Это работает, пока не случается сбой: забытый catch оставляет ложное состояние, а повторные клики гоняют несколько запросов без гарантии порядка. useOptimistic решает эти проблемы, автоматически управляя временным состоянием.
Сигнатура хука:
const [optimisticState, setOptimistic] = useOptimistic(value, reducer?);
value — реальное подтвержденное состояние. optimisticState совпадает с ним, пока вы не вызовете setter внутри Transition. Тогда оно временно отражает переданные данные. После завершения Transition optimisticState автоматически возвращается к value. Никаких ручных сбросов — это главное преимущество.
Вот пример, который объединяет оба хука. Здесь мы создаем комментарий с оптимистичным добавлением:
import { useActionState, useOptimistic, startTransition } from 'react';
function CommentBox({ comments, onConfirmed }) {
async function postComment(previousState, formData) {
if (formData === null) return { error: null };
const text = formData.get('comment');
if (!text?.trim()) return { error: 'Комментарий не может быть пустым.' };
const saved = await saveComment(text);
startTransition(() => onConfirmed({ id: saved.id, text }));
return { error: null };
}
const [state, formAction, isPending] = useActionState(postComment, { error: null });
const [optimisticComments, addOptimisticComment] = useOptimistic(comments);
async function handleSubmit(formData) {
const text = formData.get('comment');
const id = crypto.randomUUID();
addOptimisticComment((current) => [...current, { id, text }]);
return formAction(formData);
}
function handleReset() {
startTransition(() => formAction(null));
}
return (
<div>
<ul>
{optimisticComments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
<form action={handleSubmit}>
<input name="comment" disabled={isPending} />
<button disabled={isPending}>Отправить</button>
<button type="button" onClick={handleReset} disabled={isPending}>Сбросить</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
</div>
);
}
Важно: useOptimistic не обновляет реальные comments. Это делает родительский компонент через onConfirmed:
function CommentThread({ postId }) {
const [comments, setComments] = useState(initialComments);
return (
<CommentBox
comments={comments}
onConfirmed={(comment) =>
setComments((current) => [...current, comment])
}
/>
);
}
Если забыть эту связку, комментарий исчезнет после завершения Transition, даже если сервер его сохранил. Это ошибка №1, которую я совершил.
Обратите внимание: id генерируется в handleSubmit через crypto.randomUUID(), а не внутри функции обновления для addOptimisticComment. Обновляющая функция должна быть чистой (pure). Если вызвать Date.now() или randomUUID внутри неё, React может вызвать её несколько раз, и вы получите разные ключи для одного комментария.
useActionState не имеет автоматического сброса. Часто советуют менять key, чтобы пересоздать компонент. Это работает, но с useOptimistic появляется проблема: ремаунт уничтожает и оптимистичное состояние. Если Transition ещё не завершился, оптимистичный элемент просто исчезнет, не дождавшись реального.
Моя первая попытка — добавить сброс в редьюсер:
function commentReducer(current, action) {
if (action.type === 'reset') return [];
return [...current, action.item];
}
Это неправильно: current содержит весь список, включая подтвержденные комментарии. Возврат [] временно сотрет все, что уже сохранено. Пользователь увидит пустой список без причины.
Правильное решение — не сбрасывать useOptimistic вообще. Когда кнопка «Сбросить» доступна, isPending равно false, значит optimisticComments уже совпадает с comments. Сбрасывать нечего. Единственное, что нужно сбросить — ошибку в useActionState, что и делает formAction(null).
Вопрос: что если нажать «Сбросить» во время отправки? В черновике я ошибочно сказал, что React не поддерживает отмену. На самом деле есть документированный паттерн с AbortController.
Но! Отмена действия небезопасна: если сервер уже выполнил мутацию, отмена запроса не откатит её. Поэтому в handleReset мы не используем AbortController — вызов formAction(null) просто встает в очередь после текущего действия. Кнопка «Сбросить» отключена при isPending, чтобы пользователь не ждал зря.
Используйте AbortController только там, где критична отмена: например, поиск по мере ввода, где поздний ответ может запутать. Для мутаций безопаснее дать действию завершиться.
useOptimistic берет на себя откат состояния. Мои ошибки были из-за недоверия к этому: я забыл связать onConfirmed, из-за чего не было реального состояния для возврата, и добавил лишний сброс, который ломал список. Доверяйте автоматическим частям, подключайте то, что не автоматизировано, и сбрасывайте только то, что действительно требует сброса.
Попробуйте прямо сейчас: возьмите свой компонент с лайком или комментарием и перепишите его с useOptimistic. Убедитесь, что onConfirmed обновляет реальное состояние, и не добавляйте лишних сбросов. Вы почувствуете разницу в отзывчивости интерфейса.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →