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 после ответа сервера.
Ukubona_Konke
Хорошо, что requestId сравнивается на клиенте, а не завязано на порядок ответов - это стандартная ловушка debounce+async. Но раз уже есть activeRequestIdRef, но почему бы не пойти на шаг дальше и не отменять устаревший запрос через AbortController, а не просто игнорировать его ответ? Для мобильных сетей и не совсем маленького payload это реальная экономия трафика и меньше шансов забить соединение параллельными XHR. Технически это ложится ровно на тот же паттерн: signal в saveAutosaveLabAction, abort() в том месте, где сейчас создаётся новый requestId.
lemon_m Автор
Да, для обычного fetch это хороший вариант. AbortController сократил бы лишний трафик, а requestId остался бы страховкой от ответа, который успел завершиться до abort.
Но в проекте saveAutosaveLabAction вызывается как Server Action. AbortSignal в неё напрямую не передать, аргументы action сериализуются. Отмена сетевого запроса также не гарантирует, что сервер не успел выполнить запись. Поэтому requestId решает отдельную задачу, старый ответ не меняет текущий UI. Для полноценной отмены пришлось бы вынести сохранение в Route Handler и вызывать его через fetch. В Workbench payload небольшой, поэтому работает этот компактный вариант. Для большого редактора лучше использовать оба механизма, плюс проверку версии документа на сервере