Optimistic update меняет клиентское состояние до ответа сервера. Autosave отправляет новую версию после паузы во вводе. До завершения action в приложении одновременно существуют локальные данные, запрос в работе и последняя подтверждённая версия на сервере.

При optimistic create клиент может показать сущность с временным id, а сервер вернуть другой id. Ошибка после частичной записи оставляет сущность на сервере и запускает локальный rollback. При autosave ответы приходят не в порядке отправки. Старый ответ меняет статус уже после сохранения новой версии. updatedAt может перейти вперёд до завершения записи.

В учебном проекте Workbench optimistic create использует clientId для одинакового id на клиенте и сервере. Autosave-демо передаёт requestId в каждый запрос. Редактор хранит статусы saving, saved и error отдельно от текста заметки.

Optimistic insert

Клиент добавляет сущность в store до завершения server action. Форма отправляет title и clientId. Optimistic id строится из того же clientId.

const [clientId, setClientId] = useState(() => crypto.randomUUID());

const optimisticId = useMemo(
  () => `i-${clientId}`,
  [clientId]
);

<form
  action={formAction}
  onSubmit={(event) => {
    const formData = new FormData(event.currentTarget);
    const title = String(formData.get("title") ?? "").trim();

    if (title.length < 2) return;

    startTransition(() => {
      insertItemLocal({
        id: optimisticId,
        title,
        createdAt: new Date().toISOString(),
      });
    });
  }}
>
  <input type="hidden" name="clientId" value={clientId} />
  <input name="title" />
</form>

Store получает id i-${clientId} до завершения action.

Id на сервере

Server action проверяет clientId через Zod и собирает id по той же формуле.

const schema = z.object({
  title: titleSchema,
  clientId: z.string().uuid(),
});

const parsed = schema.safeParse({
  title: formData.get("title"),
  clientId: formData.get("clientId"),
});

if (!parsed.success) {
  return {
    ok: false,
    error: null,
    fieldErrors: {
      title: parsed.error.flatten().fieldErrors.title?.[0],
    },
  };
}

const itemId = `i-${parsed.data.clientId}`;

Клиентская запись и серверная запись используют один id. Временный id с последующей заменой здесь не нужен. Ссылки на сущность, выбранный элемент и optimistic state продолжают работать с тем же значением после ответа сервера.

Повторная запись

Один action может прийти повторно после retry, двойной отправки или повторного вызова формы. Сервер проверяет наличие id перед вставкой.

const itemId = `i-${parsed.data.clientId}`;
const db = await readWorkbenchDb();

if (!db.items.some((item) => item.id === itemId)) {
  db.items.unshift({
    id: itemId,
    title: parsed.data.title,
    createdAt: new Date().toISOString(),
  });

  await writeWorkbenchDb(db);
}

Повторный запрос с тем же clientId не создаёт второй объект.

После успешного ответа форма генерирует следующий clientId. Следующая optimistic-вставка получает новый id.

useEffect(() => {
  if (!state.ok) return;

  startTransition(() => {
    setClientId(crypto.randomUUID());
  });
}, [state]);

Ошибка после записи

Сервер может записать данные и вернуть ошибку позже. Сбой возникает после записи файла или базы, во время следующей операции, revalidate или подготовки ответа. Клиент получает error, хотя сущность уже существует на сервере. Локальный rollback в таком сценарии удалит сущность из store. После reload она появится снова.

В optimistic-lab добавлен partial-сценарий. Action записывает объект и возвращает ошибку завершения операции. Клиент перечитывает серверный snapshot.

if (!state.ok && (state.error || state.fieldErrors?.title)) {
  if (hadOptimisticInsertRef.current) {
    hadOptimisticInsertRef.current = false;

    void refreshFromServer();

    startTransition(() => {
      setClientId(crypto.randomUUID());
    });
  }
}

refreshFromServer заменяет локальный store данными, которые сервер вернул после записи. Для ошибок, возникших до записи, локальный rollback остаётся допустимым. Для неизвестного состояния после action используется пересинхронизация.

Autosave и debounce

Debounce удаляет таймер, если пользователь продолжил ввод до отправки запроса. Уже отправленный запрос debounce не отменяет. Пользователь меняет текст, запрос A уходит на сервер. Следующее изменение запускает запрос B. Сервер может вернуть B раньше A. Оба обработчика выполнятся, если клиент не проверяет принадлежность ответа к последнему запросу.

В autosave-lab клиент хранит requestId активного сохранения.

const activeRequestIdRef = useRef<string | null>(null);
const timerRef = useRef<number | null>(null);

useEffect(() => {
  if (timerRef.current) {
    window.clearTimeout(timerRef.current);
  }

  timerRef.current = window.setTimeout(() => {
    const requestId = crypto.randomUUID();

    activeRequestIdRef.current = requestId;
    setIsSaving(true);
    setServerError(null);

    void saveAutosaveLabAction({
      text: draft,
      requestId,
    }).then((result) => {
      // обработка ответа
    });
  }, 700);

  return () => {
    if (timerRef.current) {
      window.clearTimeout(timerRef.current);
    }
  };
}, [draft]);

Новый запрос заменяет значение в activeRequestIdRef.

RequestId в ответе

Server action возвращает requestId и при успехе и при ошибке.

export type AutosaveLabResult =
  | {
      ok: true;
      value: {
        updatedAt: string;
        requestId: string;
      };
    }
  | {
      ok: false;
      error: string;
      requestId: string;
    };

Клиент сравнивает id ответа с id последнего запроса.

const responseRequestId = result.ok
  ? result.value.requestId
  : result.requestId;

if (activeRequestIdRef.current !== responseRequestId) {
  return;
}

Ответ запроса A не меняет состояние после запуска запроса B. Проверка относится и к ошибкам. Старая ошибка не должна переводить текущую форму в error, если более новая версия уже сохранена.

Статусы сохранения

Текст заметки и статус записи хранятся отдельно. Изменение редактора обновляет draft, запуск action ставит saving. Успешный ответ активного запроса ставит saved, ошибка активного запроса ставит error.

type NoteSaveInfo =
  | {
      status: "idle";
      error: null;
      lastSavedAt?: string;
    }
  | {
      status: "saving";
      error: null;
      lastSavedAt?: string;
    }
  | {
      status: "saved";
      error: null;
      lastSavedAt: string;
    }
  | {
      status: "error";
      error: string;
      lastSavedAt?: string;
    };

Статус описывает серверную запись. Текст в редакторе может содержать более новую локальную версию.

updatedAt после ответа

Редактор меняет contentHtml в локальном store во время ввода. updatedAt и lastSavedAt меняются после успешного ответа action.

setNoteSaveInfoById((prev) => ({
  ...prev,
  [noteId]: {
    status: "saving",
    error: null,
  },
}));

const timer = window.setTimeout(() => {
  const updatedAt = new Date().toISOString();

  void updateNoteContentAction({
    noteId,
    contentHtml: nextHtml,
    updatedAt,
  }).then((result) => {
    if (!result.ok) {
      setNoteSaveInfoById((prev) => ({
        ...prev,
        [noteId]: {
          status: "error",
          error: result.error,
        },
      }));

      void refreshFromServer();
      return;
    }

    setDb((prev) => ({
      ...prev,
      notes: prev.notes.map((note) =>
        note.id === noteId
          ? { ...note, updatedAt }
          : note
      ),
    }));

    setNoteSaveInfoById((prev) => ({
      ...prev,
      [noteId]: {
        status: "saved",
        lastSavedAt: updatedAt,
        error: null,
      },
    }));
  });
}, 700);

До result.ok интерфейс показывает saving. Ошибка action не меняет updatedAt.

В autosave-lab сервер возвращает собственный updatedAt. Клиент применяет его после проверки requestId.

if (!result.ok) {
  setServerError(result.error);
  setIsSaving(false);
  return;
}

setLastSavedAt(result.value.updatedAt);
setIsSaving(false);

Пересинхронизация

Autosave может завершиться ошибкой сети, записи или валидации. Локальный draft к этому моменту уже отличается от серверной версии.

Вызывается refreshFromServer после ошибки записи. Store получает сохранённые projects, sections и notes. Статус остаётся error до следующего действия пользователя или нового сохранения.

if (!result.ok) {
  setNoteSaveInfoById((prev) => ({
    ...prev,
    [noteId]: {
      status: "error",
      error: result.error,
    },
  }));

  void refreshFromServer();
  return;
}              

Черновик редактора и серверный snapshot после такого обновления требуют отдельного решения. Можно оставить draft в поле и показать error. Можно заменить draft серверным содержимым. Выбор зависит от редактора и правил восстановления текста. В проекте локальный контент остаётся доступным во время ввода. Серверный snapshot перечитывается для восстановления store.

Проверка

Optimistic insert проверяется с одним clientId. Клиент добавляет объект в store, action записывает объект с тем же id, повторный запрос не создаёт дубль. Partial-сценарий записывает объект и возвращает ошибку. После refreshFromServer объект остаётся в UI. Локальное удаление без пересинхронизации показало бы другое состояние до reload.

Autosave проверяется несколькими изменениями текста с разной задержкой ответов. Старый requestId не меняет saved, error и updatedAt после запуска нового запроса. Ошибка записи оставляет статус error. updatedAt остаётся прежним. Следующий успешный запрос меняет lastSavedAt после ответа сервера.