Optimistic UI и autosave в Next.js без гонок и ложного сохранено

от автора

Optimistic UI и autosave выглядят как естественное улучшение интерфейса. Хочется, чтобы новый элемент появлялся сразу, текст не терялся, статус сохранения успокаивал пользователя, а редактор не дёргался на каждом символе. Но здесь интерфейс начинает врать. Новый проект уже нарисован в списке, хотя сервер его ещё не подтвердил. Статус показывает saved, хотя это был ответ от старого запроса. updatedAt обновился, хотя запись на сервере не прошла. В какой-то момент становится ясно, что скорость интерфейса и правдивость интерфейса это разные вещи.

В моем учебном проекте Workbench эта граница показана на двух местах. Первое это optimistic create для inline CRUD. Второе это autosave редактора и отдельное демо с requestId. В обоих случаях задача одна и та же. UI должен быть быстрым, но не должен придумывать серверную истину раньше времени. Иначе вместо отзывчивости получается рассинхрон.

Где optimistic UI обычно начинает лгать

Частая ошибка в optimistic insert выглядит так. Клиент создаёт временный элемент, показывает его сразу, потом сервер возвращает реальный id, а UI пытается как-нибудь склеить одно с другим. Пока всё идёт по ок сценарию, кажется, что подход рабочий. Но как только сервер ответил с задержкой, дважды, частично или с ошибкой после фактической записи, локальный rollback перестаёт быть надёжным.

В Workbench выбран более жёсткий ход. optimistic id не временный, а детерминированный. Клиент сначала генерирует clientId, а потом и UI, и сервер строят один и тот же итоговый id на его основе.

const [clientId, setClientId] = useState(() => crypto.randomUUID());const optimisticId = useMemo(() => `i-${clientId}`, [clientId]);                  

На сервере action делает то же самое.

const schema = z.object({  title: titleSchema,  clientId: z.string().uuid(),});const itemId = `i-${parsed.data.clientId}`;                  

Этот приём снимает одну из самых неприятных проблем optimistic UI. Элемент, который появился локально, и элемент, который потом подтверждает сервер, это уже не две сущности, которые надо склеивать. Это одна и та же сущность с одним и тем же id. За счёт этого исчезает целый класс лишней логики вокруг замены временного id на серверный.

Почему простой rollback не всегда надёжен

Даже с правильным id остаётся другая проблема. Не всякая серверная ошибка означает, что запись не произошла. В демо optimistic-lab в Workbench есть специально сделанный частичный сценарий. Сервер успевает записать элемент, но возвращает ошибку завершения операции. Это неприятный, но реальный класс ситуаций. Клиенту кажется, что всё нужно откатить. Сервер уже считает, что запись существует.

В таком месте хрупкий локальный rollback делает интерфейс верным только снаружи. По факту он может удалить из UI то, что уже стало серверной истиной.

Поэтому в Workbench после неуспешного optimistic submit используется не попытка восстановить локальное состояние по памяти, а пересинк от сервера.

if (!state.ok && (state.error || state.fieldErrors?.title)) {  if (hadOptimisticInsertRef.current) {    hadOptimisticInsertRef.current = false;    void refreshFromServer();    startTransition(() => {      setClientId(crypto.randomUUID());    });  }}                  

Когда есть вероятность частичной записи, надёжнее не выдумывать rollback вручную, а перечитать серверный снапшот целиком. Да, это менее эффектно, чем локальный откат без обращения к серверу. Но именно так UI снова опирается на реальное состояние, а не на предположение о том, что сервер успел или не успел сделать.

Optimistic UI нужен вместе с серверной точкой истины

В Workbench optimistic слой живёт рядом с серверной записью. Сначала UI вставляет локальный проект или секцию. Потом form action валидирует данные и делает запись. Если всё прошло успешно, локальный элемент уже совпадает с серверным по id. Если нет, можно либо убрать локальную вставку, либо, когда сценарий сомнительный, пересинхронизироваться от сервера.

На создании проекта это выглядит так.

const clientId = useMemo(() => crypto.randomUUID(), []);const optimisticId = `p-${clientId}`;<form  action={formAction}  onSubmit={(e) => {    const fd = new FormData(e.currentTarget);    const title = String(fd.get("title") ?? "").trim();    if (title.length < 2) return;    startTransition(() => {      store.insertProjectLocal({        id: optimisticId,        title,        structure,        createdAt: new Date().toISOString(),        isDemo: false,      });    });  }}>                  

А на сервере:

const clientIdRaw = String(formData.get("clientId") ?? "");const clientId = isUuid(clientIdRaw) ? clientIdRaw : crypto.randomUUID();const projectId = `p-${clientId}`;if (!db.projects.some((p) => p.id === projectId)) {  db.projects.unshift({    id: projectId,    title,    structure,    createdAt: new Date().toISOString(),    isDemo: false,  });  await writeWorkbenchDb(db);}                  

Здесь видно, что optimistic UI становится надёжнее не от того, что элемент рисуется сразу, а от того, что у него есть строгая связка с серверной write-точкой.

Autosave ломается не на debounce, а на порядке ответов

С autosave другая типовая ловушка. Обычно всё начинается правильно. Есть draft, есть debounce, после паузы отправляется сохранение. Но когда пользователь печатает быстро, несколько запросов оказываются в полёте одновременно. И вот здесь интерфейс легко начинает жить в неправильном времени. Более старый ответ приходит позже, затирает новый статус и оставляет ощущение, что всё сохранено, хотя это уже не последняя версия текста.

В отдельном autosave-демо Workbench эта проблема оформлена так. Каждый запрос получает requestId, а клиент хранит id последнего активного сохранения.

const activeRequestIdRef = useRef<string | null>(null);timerRef.current = window.setTimeout(() => {  const requestId = crypto.randomUUID();  activeRequestIdRef.current = requestId;  setIsSaving(true);  setServerError(null);  void saveAutosaveLabAction({ text: draft, requestId })                  

Сервер возвращает этот же requestId обратно.

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

А клиент перед обработкой результата сначала проверяет, относится ли ответ к последнему запросу.

const rRequestId = r.ok ? r.value.requestId : r.requestId;if (activeRequestIdRef.current !== rRequestId) return;                  

Это место важнее, чем сам debounce. Debounce уменьшает число запросов. Но от гонок он не защищает. Защищает именно правило, что UI имеет право принимать только результат последнего сохранения.

Почему autosave не должен обновлять updatedAt до подтверждения сервера

Ощущение ложного сохранено часто рождается в одном и том же месте. Пользователь печатает, интерфейс мгновенно меняет updatedAt или lastSavedAt, а реальная запись ещё даже не завершилась. Формально это выглядит как отзывчивость. По смыслу это уже обман.

В Workbench редактор обновляет contentHtml локально сразу, но время сохранения меняет только после подтверждённого ok от сервера.

В store это видно прямо внутри scheduleSaveNote.

setNoteSaveInfoById((prev) => ({  ...prev,  [noteId]: { status: "saving", error: null },}));const timer = window.setTimeout(() => {  const updatedAt = new Date().toISOString();  void updateNoteContentAction({ noteId, contentHtml: nextHtml, updatedAt })    .then((r) => {      if (!r.ok) {        setNoteSaveInfoById((prev) => ({          ...prev,          [noteId]: { status: "error", error: r.error },        }));        void refreshFromServer();        return;      }      setDb((prev) => ({        ...prev,        notes: prev.notes.map((n) => (n.id === noteId ? { ...n, updatedAt } : n)),      }));      setNoteSaveInfoById((prev) => ({        ...prev,        [noteId]: { status: "saved", lastSavedAt: updatedAt, error: null },      }));    })                  

Пользователь может видеть, что текст уже в draft и локально отображается в редакторе. Но статус saved и новое updatedAt появляются только после успешной server action. Это и есть верное разделение между черновиком и подтверждённой записью.

Локальный текст может обновляться сразу, серверная метка времени нет

В autosave нет необходимости тормозить ввод до ответа сервера. Наоборот, draft должен жить свободно и отзывчиво. Но это не означает, что все производные признаки сохранения тоже можно рисовать заранее.

В Workbench локальная модель разделена. Контент обновляется сразу, чтобы редактор не дёргался. save status отдельно проходит через saving, saved и error. lastSavedAt появляется только после ok. При ошибке делается refreshFromServer, потому что интерфейс не должен продолжать притворяться, будто локальное состояние уже гарантированно совпадает с серверным.

За счёт этого autosave становится не просто быстрым, а правдивым. Пользователь может спокойно печатать дальше, но UI не делает вид, что сервер уже подтвердил то, чего он ещё не подтвердил.

Почему refreshFromServer нужен и в autosave, и в optimistic сценариях

На первый взгляд optimistic create и autosave это разные темы. В одном случае речь про мгновенное появление нового элемента, в другом про сохранение текста по таймеру. Но архитектурный вывод у них общий. В обоих сценариях локальный UI может уйти вперёд быстрее, чем серверная истина. И в обоих случаях нужен ясный способ вернуться к реальности.

В проекте таким страховочным механизмом остаётся refreshFromServer. Он используется не как постоянный костыль, а как надёжный выход из пограничных состояний. Если optimistic submit попал в частичную серверную ситуацию, перечитывается снапшот. Если autosave дал ошибку записи или сети, перечитывается снапшот. Это полезнее, чем пытаться руками достроить все возможные rollback-ветки внутри клиента.

Что в итоге меняется в голове

Интерфейс можно ускорять только до той точки, пока он не начинает лгать о подтверждённых данных. Если optimistic элемент можно детерминированно связать с серверной записью через clientId, это хорошее ускорение. Если сомнительную ситуацию после ошибки надёжнее лечить refreshFromServer, это лучше, чем хрупкий rollback. Если autosave получает requestId и игнорирует старые ответы, это защита от гонок. Если updatedAt меняется только после ok, это честный статус, а не декоративный.

Когда такие правила собраны вместе, optimistic UI и autosave становятся нормальной инженерной дисциплиной вокруг одного вопроса — как оставить интерфейс быстрым, не разрушая доверие к его состояниям.

Итог

Optimistic UI и autosave нужны там, где быстрый интерфейс остаётся правдивым. Для этого мало мгновенно вставить элемент или запустить debounce. Нужны более жёсткие опоры. Детерминированный id через clientId, серверная write-точка, refreshFromServer как страховка, requestId для последнего запроса, separate save status и updatedAt только после подтверждённой записи.

Так исчезает два неприятных эффекта. Гонки перестают портить данные. Статус сохранения перестаёт обещать больше, чем реально подтвердил сервер.

Для примеров в статье использован проект Workbench.

ссылка на оригинал статьи https://habr.com/ru/articles/1059760/