Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Оптимистичные обновления с useOptimistic: как правильно типизировать состояние и откат в React 19

Тимофей Ищенко

Коротко: Разбираем хук useOptimistic в React 19 и TypeScript: архитектура типизации, работа редьюсера, автоматический rollback при ошибках и практические примеры.

Оптимистичный UI давно стал стандартом интерактивных веб-приложений. Пользователь не должен ждать ответа сервера, чтобы увидеть добавленный комментарий, изменение статуса задачи или переключенный тумблер. До релиза React 19 реализация таких интерфейсов требовала ручного сохранения предыдущего состояния, громоздких блоков try/catch и россыпи вспомогательных флагов.

Хук useOptimistic формализует эту работу на уровне ядра React. Он связывает жизненный цикл асинхронного действия (Action / Transition) с временным состоянием интерфейса. Чтобы эта связка работала надежно в масштабных проектах, важно понимать модель жизненного цикла хука и правильно выстроить систему типов в TypeScript.


Смена парадигмы: от ручного rollback к модели состояний React 19

Почему ручной откат через useState и try/catch порождает техдолг

Классический подход к оптимистичным обновлениям строился на ручном манипулировании состоянием:

  1. Сохранить текущее состояние в локальную переменную (previousState).
  2. Немедленно вызвать setState с предполагаемым новым значением.
  3. Отправить сетевой запрос.
  4. В блоке catch вручную вызвать setState(previousState), чтобы вернуть UI назад при сбое.

Такой паттерн создает серьезные проблемы при конкурентных запросах. Если пользователь отправляет несколько действий подряд (например, быстро ставит лайк, пишет комментарий и удаляет его), параллельные блоки try/catch начинают перезаписывать состояние друг друга. В результате интерфейс рассинхронизируется с реальными данными на сервере, а кодовая база обрастает сложными условиями для разрешения конфликтов.

Жизненный цикл useOptimistic: как React связывает Pending Action и Base State

React 19 решает проблему за счет декларативной связи с переходами (startTransition) или Server Actions.

Хук useOptimistic не хранит постоянное независимое состояние. Он принимает базовое подтвержденное значение (passthrough value), которое приходит через props или из родительского источника данных.

[Базовое состояние (Base State)] 
               │
               ▼
   [Action / startTransition] ──► [setOptimistic(payload)] ──► [Optimistic State в UI]
               │
      ┌────────┴────────┐
      ▼                 ▼
  [Успех]            [Ошибка]
      │                 │
      ▼                 ▼
[Новый Base State] [Откат к старому Base State]

Пока асинхронный Action находится в статусе pending, React отображает вычисленное оптимистичное состояние. Как только Action завершается (успешно или с ошибкой), React автоматически отбрасывает временные данные. Если действие выполнено успешно, компонент перерисовывается с новым базовым состоянием. Если произошел сбой, React возвращает отображение к исходному базовому значению. Механизм отката работает из коробки и не требует написания ручных rollback-функций.


Анатомия хука useOptimistic и принципы работы редьюсера

Сигнатура хука и назначение функции обновления

Хук принимает два аргумента и возвращает кортеж из двух элементов:

function useOptimistic<State, ActionPayload>(
  passthrough: State,
  updateFn?: (currentState: State, optimisticValue: ActionPayload) => State
): [State, (optimisticValue: ActionPayload) => void];
  1. passthrough — актуальное, подтвержденное состояние (например, список элементов из базы данных).
  2. updateFn (опционально) — чистая функция-редьюсер, принимающая текущее состояние и полезную нагрузку (ActionPayload), возвращающая следующее оптимистичное состояние.
  3. Возвращаемые значения — кортеж: текущее оптимистичное состояние и функция-триггер обновления (setOptimistic).

Если updateFn не передан, функция-триггер заменяет текущее состояние переданным значением. Для простых примитивов (булевы флаги, счетчики) этого достаточно, но для списков и вложенных структур необходим редьюсер.

Чистота редьюсера: почему иммутабельность критична

Функция обновления внутри useOptimistic обязана быть чистой. React может вызывать её повторно при пересчете интерфейса. Мутация исходного массива или объекта приведет к трудноуловимым багам:

// НЕПРАВИЛЬНО: прямая мутация базового состояния
const updateBad = (state: Item[], newItem: Item) => {
  state.push(newItem); // Ломает внутренний механизм отката React
  return state;
};

// ПРАВИЛЬНО: возврат нового иммутабельного состояния
const updateGood = (state: Item[], newItem: Item): Item[] => {
  return [...state, newItem];
};

Архитектура типизации: связываем Action, Optimistic State и Base State

Определение базовой модели и оптимистичных метаданных

Чтобы пользователь понимал, какие элементы уже сохранены, а какие находятся в процессе отправки, базовый тип расширяется метаданными:

export interface Message {
  id: string;
  text: string;
  createdAt: string;
}

// Расширяем базовый тип для UI-слоя
export interface OptimisticMessage extends Message {
  isSending?: boolean;
  tempId?: string;
}

Использование Discriminated Unions для составных операций

Если в одном компоненте оптимистично выполняются разные операции (создание, удаление, редактирование), их полезную нагрузку нужно объединить через размеченное объединение (Discriminated Union):

export type OptimisticAction =
  | { type: 'ADD'; payload: { text: string; tempId: string } }
  | { type: 'DELETE'; payload: { id: string } }
  | { type: 'EDIT'; payload: { id: string; newText: string } };

Такая структура исключает ошибки невалидных полей на этапе компиляции TypeScript.

Служебные типы TypeScript для защиты контрактов

Для описания контрактов полезно использовать стандартные утилиты TypeScript:

// Защищаем редьюсер от случайных мутаций входного состояния
export type OptimisticState = ReadonlyArray<OptimisticMessage>;

// Извлекаем полезную нагрузку конкретного действия
export type AddPayload = Extract<OptimisticAction, { type: 'ADD' }>['payload'];

// Гарантируем, что сигнатура функции обновления строго соответствует типу хука
export type OptimisticReducer = (
  state: OptimisticState,
  action: OptimisticAction
) => OptimisticState;

Практическая реализация: типизированная лента с добавлением и удалением

Ниже представлен рабочий пример компонента заметок с оптимистичным добавлением, удалением и автоматическим откатом при сетевых сбоях.

1. Объявление типов

// types.ts
export interface Note {
  readonly id: string;
  readonly title: string;
}

export interface OptimisticNote extends Note {
  readonly isPending?: boolean;
}

export type NoteAction =
  | { type: 'ADD'; payload: { tempId: string; title: string } }
  | { type: 'DELETE'; payload: { id: string } };

2. Реализация чистого типизированного редьюсера

// noteReducer.ts
import { OptimisticNote, NoteAction } from './types';

export function notesReducer(
  state: readonly OptimisticNote[],
  action: NoteAction
): readonly OptimisticNote[] {
  switch (action.type) {
    case 'ADD':
      return [
        ...state,
        {
          id: action.payload.tempId,
          title: action.payload.title,
          isPending: true,
        },
      ];

    case 'DELETE':
      return state.filter((note) => note.id !== action.payload.id);

    default: {
      const _exhaustiveCheck: never = action;
      return state;
    }
  }
}

3. Компонент с интеграцией Transition и Action

// NotesList.tsx
import React, { useOptimistic, useTransition, useRef } from 'react';
import { Note, OptimisticNote, NoteAction } from './types';
import { notesReducer } from './noteReducer';

interface NotesListProps {
  initialNotes: readonly Note[];
  onAddNoteAction: (title: string) => Promise<Note>;
  onDeleteNoteAction: (id: string) => Promise<void>;
}

export const NotesList: React.FC<NotesListProps> = ({
  initialNotes,
  onAddNoteAction,
  onDeleteNoteAction,
}) => {
  const [isPending, startTransition] = useTransition();
  const formRef = useRef<HTMLFormElement>(null);

  const [optimisticNotes, dispatchOptimistic] = useOptimistic<
    readonly OptimisticNote[],
    NoteAction
  >(initialNotes, notesReducer);

  const handleAdd = async (formData: FormData) => {
    const title = formData.get('title') as string;
    if (!title.trim()) return;

    const tempId = `temp-${crypto.randomUUID()}`;

    startTransition(async () => {
      dispatchOptimistic({
        type: 'ADD',
        payload: { tempId, title },
      });

      formRef.current?.reset();

      try {
        await onAddNoteAction(title);
      } catch (error) {
        // При ошибке Action завершается, и React автоматически
        // возвращает optimisticNotes к исходному значению initialNotes.
        console.error('Не удалось сохранить заметку на сервере:', error);
      }
    });
  };

  const handleDelete = (id: string) => {
    startTransition(async () => {
      dispatchOptimistic({
        type: 'DELETE',
        payload: { id },
      });

      try {
        await onDeleteNoteAction(id);
      } catch (error) {
        console.error('Ошибка при удалении заметки:', error);
      }
    });
  };

  return (
    <div>
      <form ref={formRef} action={handleAdd}>
        <input name="title" placeholder="Новая заметка..." required />
        <button type="submit" disabled={isPending}>
          Добавить
        </button>
      </form>

      <ul>
        {optimisticNotes.map((note) => (
          <li
            key={note.id}
            style={{ opacity: note.isPending ? 0.5 : 1.0 }}
          >
            <span>{note.title}</span>
            {note.isPending && <em> (сохранение...)</em>}
            <button
              onClick={() => handleDelete(note.id)}
              disabled={note.isPending}
            >
              Удалить
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

Частые архитектурные ошибки и антипаттерны

Вызов setOptimistic вне Transition / Action

Функция обновления привязана к контексту асинхронного перехода. Если вызвать setOptimistic в обычном синхронном обработчике событий без startTransition, React выведет предупреждение в консоль, а жизненный цикл обновления будет нарушен.

// ОШИБКА
const handleClick = () => {
  setOptimistic({ type: 'ADD', payload: { ... } }); // Предупреждение React!
};

// ПРАВИЛЬНО
const handleClick = () => {
  startTransition(async () => {
    setOptimistic({ type: 'ADD', payload: { ... } });
    await apiCall();
  });
};

Попытка вручную сбросить оптимистичное состояние

Разработчики, привыкшие к Redux или ручному useState, иногда пытаются отправить компенсирующее действие в блоке catch:

// АНТИПАТТЕРН для useOptimistic
try {
  await apiCall();
} catch {
  // Ненужное действие: React сделает откат автоматически после завершения Action
  dispatchOptimistic({ type: 'REVERT', payload: { ... } }); 
}

Поскольку оптимистичное состояние существует только во время выполнения транзишна, завершение асинхронной функции автоматически приводит к откату до базового значения.

Ошибки вывода типов при работе с Server Actions

При передаче Server Actions напрямую в форму или обработчик важно сохранять типизацию аргументов и возвращаемых значений:

type ServerAction = (formData: FormData) => Promise<{ success: boolean }>;

// Извлечение аргументов для типизации промежуточных хелперов
type ServerActionArgs = Parameters<ServerAction>;

Чек-лист для код-ревью

При внедрении useOptimistic проверьте следующие критерии:

  1. Иммутабельность: Редьюсер возвращает новые ссылки на массивы и объекты, входное состояние защищено через readonly или ReadonlyArray.
  2. Контекст выполнения: Каждый вызов setOptimistic находится внутри Server Action или обернут в startTransition.
  3. Отсутствие избыточного rollback-кода: В блоках catch нет ручного сброса оптимистичного состояния.
  4. Типизация действий: Все оптимистичные события описаны через Discriminated Union с проверкой на исчерпываемость (_exhaustiveCheck: never).
  5. Временные идентификаторы: Элементы, созданные оптимистично, имеют сгенерированные временные ключи (tempId) для стабильности атрибута key в списках.

Часто задаваемые вопросы (FAQ)

1. Как происходит откат (rollback), если сервер вернул ошибку 500?

React завершает Action с ошибкой и автоматически отбрасывает вычисленное оптимистичное состояние. Интерфейс возвращается к последнему подтвержденному значению (passthrough value), переданному первым аргументом в useOptimistic.

2. Нужно ли писать отдельный тип для rollback-состояния?

Нет. Откат в модели React 19 — это не отдельная сущность, а возврат к базовому состоянию компонента. Типизировать требуется только базовое состояние и оптимистичные полезные нагрузки (ActionPayload).

3. Можно ли использовать useOptimistic в клиентском SPA без Next.js и Server Actions?

Да. Хук useOptimistic является частью базовой библиотеки React 19. Он работает в любых клиентских сборках (Vite, Webpack), если вызов функции обновления обернут в клиентский хук startTransition.

4. Что произойдет, если вызвать setOptimistic вне Transition?

React зафиксирует невалидный контекст исполнения и выведет предупреждение в консоли. В таком сценарии React не сможет отследить момент завершения асинхронной операции, что приведет к некорректному отображению данных и рассинхронизации UI.

5. Как обрабатывать очереди из нескольких параллельных оптимистичных действий?

React объединяет вызовы useOptimistic внутри активных переходов. Если редьюсер написан как чистая функция, React последовательно применяет все накопленные оптимистичные действия поверх актуального базового состояния.


Вывод

Хук useOptimistic в React 19 стандартизирует архитектуру отзывчивых пользовательских интерфейсов. За счет переноса ответственности за откат на сам фреймворк разработчикам больше не требуется вручную синхронизировать состояние при сбоях. Использование строгой типизации редьюсеров, дискриминантных объединений и вспомогательных типов TypeScript превращает оптимистичные обновления в предсказуемый и безопасный инструмент без накопления технического долга.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также