lenec ru

← все посты

useOptimistic в React: типичные ошибки

18K

Хук 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, но не заменяет ревалидацию и обработку ошибок. Тестируй сценарии «всё хорошо», «упало», «упало с задержкой», «двойной клик». Если в каждом из них поведение разумное — паттерн работает. Если в каком-то — нет, скорее всего где-то одна из шести ошибок выше.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.