В Next.js формы и inline CRUD довольно быстро упираются в одну и ту же развилку. Можно пойти привычным путём и собрать ручной API: отдельный route handler, fetch из клиента, локальные флаги pending, error, success, плюс своя логика для blur, Enter, Escape и закрытия редактора. На небольшом примере это выглядит терпимо. Но как только в проекте появляются создание, переименование, удаление и несколько inline-форм на одном экране, код начинает расползаться не по бизнес-логике, а по обвязке.

Проблема в количестве промежуточных слоёв между формой и записью данных. Отдельный endpoint, отдельный клиентский submit, отдельный формат ответа, отдельные флаги состояния, отдельная синхронизация UI после успеха или ошибки. Для таких сценариев Server Actions в App Router нужны потому, что для форм и inline-редактирования дают более короткую и предсказуемую write-точку.

В проекте примере Workbench покажем на создании, переименовании и удалении проектов, секций и заметок. У формы есть action, серверная функция получает FormData, возвращает типизированное состояние, а клиент живёт вокруг одного паттерна: state, formAction, isPending. В результате форма собирается как связанный цикл, а не как набор разрозненных обработчиков.

Недостатки ручного API

На уровне архитектуры ручной API для таких форм создаёт два лишних разрыва.

Первый разрыв между формой и записью. Пользователь редактирует title прямо в дереве проекта, но вместо простой записи получается длинная цепочка: клиент ловит событие, собирает payload, вызывает fetch, ждёт JSON, парсит ответ, обновляет локальные флаги, потом отдельно закрывает inline-редактор.

Второй разрыв между результатом операции и UI. У каждой формы быстро появляется свой формат ответа. Где-то приходит success boolean, где-то error string, где-то fieldErrors, где-то ничего. Как только таких форм становится несколько, интерфейс уже не получает один понятный контракт, а начинает угадывать.

Для простого inline CRUD это почти всегда слишком дорого. Особенно если форма по смыслу живёт рядом с компонентом и должна реагировать на Enter, blur, Escape и pending без отдельного слоя клиентской сетевой логики.

Чтобы Server Actions ложились естественно

В App Router форма может быть связана с серверной функцией напрямую. Это убирает промежуточный ручной fetch и делает место записи явным. Не нужно придумывать отдельный write-endpoint для каждого действия, если сама операция по смыслу уже является формой.

В Workbench серверный слой для таких форм собран отдельно.

// src/server/actions/workbenchFormActions.ts
"use server";

import { revalidatePath } from "next/cache";
import { readWorkbenchDb, writeWorkbenchDb } from "@/server/workbenchDb";
import { titleSchema } from "@/lib/schemas";
import type { RenameProjectFormState } from "@/lib/formTypes";

export async function renameProjectFromForm(
  _prev: RenameProjectFormState,
  formData: FormData
): Promise<RenameProjectFormState> {
  const projectId = String(formData.get("projectId") ?? "");
  const titleRaw = String(formData.get("title") ?? "");

  if (!projectId) {
    return { ok: false, error: "Нет projectId", fieldErrors: {} };
  }

  const parsed = titleSchema.safeParse(titleRaw);
  if (!parsed.success) {
    return {
      ok: false,
      error: null,
      fieldErrors: {
        title: parsed.error.issues[0]?.message ?? "Некорректное название",
      },
    };
  }

  const db = await readWorkbenchDb();
  const project = db.projects.find((x) => x.id === projectId);

  if (!project) {
    return { ok: false, error: "Проект не найден", fieldErrors: {} };
  }

  project.title = parsed.data;
  await writeWorkbenchDb(db);
  revalidatePath("/");

  return { ok: true, projectId, clientId: projectId };
}

Action получает предыдущее состояние формы и FormData, возвращает один из допустимых результатов. То есть write-точка не просто пишет данные. Она сразу формирует ответ в том виде, в котором UI умеет его читать.

write-точка живет именно в actions

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

Это видно и в другом слое Workbench, где рядом лежат payload-based server actions для мутаций.

// src/app/_actions/mutations.ts
"use server";

import { revalidatePath } from "next/cache";
import { RenameProjectPayloadSchema } from "@/lib/schemas";
import { readWorkbenchDb, writeWorkbenchDb } from "@/server/workbenchDb";

type Result = { ok: true } | { ok: false; error: string };

export async function renameProjectAction(payload: unknown): Promise<Result> {
  const parsed = RenameProjectPayloadSchema.safeParse(payload);
  if (!parsed.success) return { ok: false, error: parsed.error.message };

  const { projectId, title } = parsed.data;
  const db = await readWorkbenchDb();
  const project = db.projects.find((x) => x.id === projectId);

  if (!project) return { ok: false, error: "Проект не найден" };

  project.title = title;
  await writeWorkbenchDb(db);
  revalidatePath("/");

  return { ok: true };
}

Для inline-форм и обычных form submit лучше ложатся actions, принимающие FormData и работающие через useActionState. Для других мутаций можно использовать payload-based actions. Но в обоих случаях запись идёт не через ручной fetch в клиенте, а через серверную write-точку.

Предсказуемость формы через useActionState

Центральный паттерн в таких формах это useActionState. Он не только убирает ручной fetch. Он даёт форме три элемента с ясной ролью: state, formAction и isPending.

В демо Workbench это показано почти в чистом виде.

// src/app/(demo)/form-lab/form-lab-client.tsx
"use client";

import { useActionState, useState } from "react";
import { submitFormLabFromForm } from "@/demo/server/actions/formLabActions";
import { formLabFormInitialState } from "@/lib/formStates";
import type { FormLabFormState } from "@/lib/formTypes";

export default function FormLabClient() {
  const [title, setTitle] = useState("");

  const [state, formAction, isPending] = useActionState<FormLabFormState, FormData>(
    submitFormLabFromForm,
    formLabFormInitialState
  );

  return (
    <form
      action={formAction}
      onSubmit={() => {
        setTitle("");
      }}
    >
      <input
        name="title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        disabled={isPending}
      />

      <button type="submit" disabled={title.trim().length === 0 || isPending}>
        {isPending ? "Отправка…" : "Отправить"}
      </button>

      {state.ok === false && state.fieldErrors.title ? (
        <div>{state.fieldErrors.title}</div>
      ) : state.ok === false && state.error ? (
        <div>{state.error}</div>
      ) : null}
    </form>
  );
}

Форма здесь не хранит вручную server error, pending и success в отдельных useState. Всё это приходит из одного механизма. За счёт этого inline CRUD перестаёт быть набором флагов вокруг запроса. У него появляется единая схема состояния.

Почему initialState лучше держать отдельно от UI

Начальное состояние формы лучше держать не внутри компонента, а отдельно. Тогда UI не придумывает форму результата сам по месту, а получает заранее описанный контракт.

В проекте для этого отдельный слой formStates.

// src/lib/formStates.ts
import type {
  CreateProjectFormState,
  RenameProjectFormState,
  DeleteProjectFormState,
} from "@/lib/formTypes";

export const createProjectFormInitialState: CreateProjectFormState = {
  ok: false,
  error: null,
  fieldErrors: {},
};

export const renameProjectFormInitialState: RenameProjectFormState = {
  ok: false,
  error: null,
  fieldErrors: {},
};

export const deleteProjectFormInitialState: DeleteProjectFormState = {
  ok: false,
  error: null,
};

А рядом лежит тип результата.

// src/lib/formTypes.ts
export type RenameProjectFormState =
  | { ok: true; projectId: string; clientId: string }
  | { ok: false; error: string | null; fieldErrors: { title?: string } };

export type DeleteProjectFormState =
  | { ok: true; projectId: string }
  | { ok: false; error: string | null };

Компонент не решает на месте, что считать успешным состоянием, что ошибкой и какие поля в ответе допустимы. Всё это уже описано рядом с action-слоем, а useActionState просто связывает форму с этим контрактом.

submit и ритм взаимодействия в Inline CRUD

Обычная форма может жить вокруг кнопки сохранить. Inline CRUD так не работает. Пользователь ожидает другой ритм. Enter сохраняет, blur тоже сохраняет, Escape отменяет, pending не должен ломать строку дерева, ошибки должны появляться у поля, а не где-то в стороне.

В Workbench это оформлено прямо в action-клиентах. Например, переименование проекта.

// src/components/forms/RenameProjectInlineActionClient.tsx
"use client";

import { useActionState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { renameProjectFromForm } from "@/server/actions/workbenchFormActions";
import { renameProjectFormInitialState } from "@/lib/formStates";
import { useWorkbenchStore } from "@/lib/workbenchStore";

export default function RenameProjectInlineActionClient({
  projectId,
  initialValue,
  onCancel,
}: {
  projectId: Id;
  initialValue: string;
  onCancel: () => void;
}) {
  const store = useWorkbenchStore();
  const formRef = useRef<HTMLFormElement | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);

  const [state, formAction, isPending] = useActionState(
    renameProjectFromForm,
    renameProjectFormInitialState
  );

  const router = useRouter();

  useEffect(() => {
    inputRef.current?.focus();
    inputRef.current?.select();
  }, []);

  const submitAndClose = () => {
    const raw = inputRef.current?.value ?? "";
    const title = raw.trim();
    if (title.length < 2) return;

    store.renameProjectLocal(projectId, title);
    formRef.current?.requestSubmit();
    router.refresh();
    onCancel();
  };

  return (
    <form ref={formRef} action={formAction}>
      <input type="hidden" name="projectId" value={projectId} />
      <input
        ref={inputRef}
        name="title"
        defaultValue={initialValue}
        disabled={isPending}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            e.stopPropagation();
            submitAndClose();
          }
          if (e.key === "Escape") {
            e.preventDefault();
            e.stopPropagation();
            onCancel();
          }
        }}
        onBlur={() => {
          submitAndClose();
        }}
      />
    </form>
  );
}

Видно, почему для такого сценария Server Actions удобнее ручного API. Форма не думает о fetch и response parsing. Она думает о поведении inline-редактора. Enter сохранить, blur сохранить, Escape отменить, pending заблокировать поле, ошибку показать рядом. Серверная запись уже привязана к форме и не требует отдельной клиентской сетевой механики.

Pending UI не делает лишнюю работу

Ещё одна типовая ошибка в таких формах это перегрузить UI реакцией на pending. Добавляется отдельный state, спиннер живёт своей жизнью, кнопки блокируются в одном месте, input в другом, родительский компонент узнаёт о pending через третий канал. В итоге ощущение простого inline-edit исчезает, pending остаётся лёгким сигналом, а не отдельным сценарием. Поле получает disabled, форма слегка приглушается, рядом появляется небольшой индикатор.

// фрагмент CreateSectionInlineActionClient
const [state, formAction, isPending] = useActionState(
  createSectionFromForm,
  createSectionFormInitialState
);

return (
  <form
    action={formAction}
    className={`space-y-1 ${isPending ? "opacity-60" : ""}`}
  >
    <input
      name="title"
      disabled={isPending}
      onBlur={() => {
        formRef.current?.requestSubmit();
      }}
    />

    {isPending && (
      <span
        aria-label="Сохраняем…"
        className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-transparent"
      />
    )}
  </form>
);

Так pending не превращается в дополнительный слой интерфейсной сложности. Пользователь видит, что действие выполняется, но сама строка дерева не ломается и не выпадает из естественного ритма.

Сочетание и независимость inline CRUD от optimistic UI

В Workbench этот слой связан ещё и с локальным store. До подтверждения сервера UI может сделать optimistic-вставку или optimistic-rename, а при ошибке откатиться. Это видно в CreateProjectInlineActionClient, CreateSectionInlineActionClient, RenameSectionInlineActionClient и других формах.

Например, создание проекта сначала вставляет локальную запись, а потом при ошибке убирает её.

// фрагмент CreateProjectInlineActionClient
const clientId = useMemo(() => crypto.randomUUID(), []);
const optimisticId = `p-${clientId}`;

const [state, formAction, isPending] = useActionState(
  createProjectFromForm,
  createProjectFormInitialState
);

useEffect(() => {
  if (state.ok === false && (state.error || state.fieldErrors?.title)) {
    startTransition(() => store.removeProjectLocal(optimisticId));
  }
}, [state, store, optimisticId]);

<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,
      });
    });
  }}
>

Не смешиваем две вещи. Server Actions и useActionState уже дают устойчивый цикл формы даже без optimistic UI. Локальный optimistic-слой это следующий шаг, а не обязательное условие. Хорошая база сначала строится на write-точке, типизированном form state и предсказуемом поведении формы. А потом уже можно ускорять интерфейс локальными обновлениями.

Полезность useActionState именно для inline-форм

В больших формах его достоинства тоже заметны, но в inline CRUD они становятся почти наглядными. Потому что у таких форм мало места на экране и мало терпимости к лишней механике. Если переименование заметки требует отдельного fetch, ручной сериализации, трёх useState и дополнительного редьюсера только ради того, чтобы сохранить строку по blur, значит архитектура взяла на себя слишком много.

useActionState в таких местах нужен именно как сокращение пути. Сервер возвращает typed state, клиент связывает его с формой, pending уже встроен, FormData приходит естественно, формат ошибок задан заранее. Остаётся только поведение самого inline-редактора.

В Workbench это видно на формах создания и переименования, где один и тот же ритм повторяется несколько раз: скрытые поля с id, input, requestSubmit на blur, Enter для сохранения, Escape для отмены, небольшой pending-индикатор, fieldErrors рядом с полем, rollback при неуспехе.

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

Полезность не только в том, чтобы убрать ручной API. Форма и запись перестают восприниматься как два отдельных мира, которые надо склеивать fetch-запросом. Они начинают восприниматься как одна операция с ясной write-точкой и предсказуемым результатом.

Сначала action получает FormData и проводит серверную валидацию. Потом возвращает строгое состояние формы. Клиент получает state, formAction и isPending. Дальше inline UI уже не придумывает свой транспортный слой, а занимается своим настоящим делом: фокусом, Enter, blur, Escape, локальным pending и отображением ошибки.

Поэтому для inline CRUD в App Router Server Actions ощущаются как более естественная архитектурная форма.

Небольшой итог

Если inline CRUD в Next.js пока собран через ручной API, это не значит, что он сломан. Но довольно часто это означает, что у формы появился лишний транспортный слой, который приходится поддерживать отдельно от самой логики редактирования.

Паттерн Server Actions плюс useActionState даёт более короткую схему. Write-точка живёт на сервере. Начальное состояние формы вынесено отдельно. Ответ action заранее типизирован. Pending приходит из хука, а не из ручного флага. Inline-форма занимается тем, что ей действительно нужно: сохранить по Enter или blur, отмениться по Escape, показать локальную ошибку и не иметь лишнюю сетевую механику.

Для примеров в статье использован живой проект Workbench.
Полная последовательная сборка этих паттернов разобрана в Stepik-курсе Next.js II: TypeScript 2026.