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 после ответа сервера.
ссылка на оригинал статьи https://habr.com/ru/articles/1066080/