Next.js, optimistic UI и autosave

от автора

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/