Мне нравится направление, в котором движутся React и Next.js: нативные формы, Server Actions, меньше клиентского JavaScript, больше progressive enhancement.
На уровне идеи это очень красиво.
Но как только форма становится сложнее, чем одно поле email, выясняется, что вокруг нее снова появляется много однотипного glue code:
достать значения из
FormDataсобрать массивы и вложенные поля
провалидировать все через
zodпревратить ошибки
zodв удобный объект для UIвернуть предсказуемый
stateдляuseActionStateснова руками прописывать
defaultValue,defaultChecked,aria-invalid,aria-describedbyповторять это в каждой следующей форме
В какой-то момент я поймала себя на мысли, что пишу не бизнес-логику, а одну и ту же инфраструктуру вокруг форм.
Так появился мой pet-project: typed-form-actions.
Это небольшая библиотека для React и Next.js, которая связывает вместе FormData, zod и useActionState, чтобы формы оставались нативными, но при этом были типизированными и удобными в работе.
В чем именно боль
Если брать связку Next.js App Router + Server Actions + zod, то типичный сценарий выглядит так:
Пользователь отправляет форму.
В
Server ActionприходитFormData.Мы достаем поля вручную.
Собираем из плоской структуры объект.
Валидируем его через
zod.Возвращаем ошибки для полей.
На клиенте снова руками связываем все это с
useActionState.
То есть вроде бы стек современный, а количество рутины все еще очень заметное.
Вот очень типичный код без дополнительного слоя абстракции:
"use server";
import { z } from "zod";
const contactSchema = z.object({
name: z.string().min(2, "Name is too short."),
email: z.string().email("Enter a valid email."),
message: z.string().min(10, "Message is too short."),
});
export async function sendMessage(prevState: any, formData: FormData) {
const rawValues = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const parsed = contactSchema.safeParse(rawValues);
if (!parsed.success) {
return {
status: "error",
values: rawValues,
data: null,
formError: "Please correct the highlighted fields and try again.",
fieldErrors: parsed.error.flatten().fieldErrors,
submittedAt: Date.now(),
};
}
try {
await saveMessage(parsed.data);
return {
status: "success",
values: parsed.data,
data: { ok: true },
formError: null,
fieldErrors: {},
submittedAt: Date.now(),
};
} catch (error) {
return {
status: "error",
values: rawValues,
data: null,
formError: "Something went wrong.",
fieldErrors: {},
submittedAt: Date.now(),
};
}
}
Сама по себе эта функция не страшная.
Проблема в том, что она потом появляется еще раз, и еще раз, и еще раз. А если в форме есть массивы, чекбоксы или вложенные поля вроде profile.name и links[0].href, то кода становится еще больше.
Чего мне хотелось от библиотеки
Мне не хотелось делать еще один гигантский form framework.
Наоборот, хотелось очень узкую библиотеку с понятной задачей:
не ломать нативные HTML forms
не уводить все в client-only логику
хорошо работать с Server Actions
нормально дружить с zod
давать стабильный action state
не заставлять руками собирать одно и то же в каждом проекте
То есть идея была не в том, чтобы заменить все существующие form-решения, а в том, чтобы закрыть довольно конкретную боль вокруг modern React / Next.js forms.
Что в итоге получилось
В итоге API библиотеки строится вокруг двух вещей:
createFormAction
useActionForm
1. createFormAction
Она принимает zod-схему и хендлер, а на выходе дает функцию, которую можно использовать как action для формы.
import { createFormAction } from "typed-form-actions";
import { z } from "zod";
const newsletterSchema = z.object({
email: z.string().email("Enter a valid email address."),
topics: z.array(z.string()).min(1, "Pick at least one topic."),
marketing: z
.preprocess((value) => value === "on", z.boolean())
.default(false),
});
export const subscribeAction = createFormAction({
schema: newsletterSchema,
async handler(values) {
return {
message: Subscribed ${values.email},
topicsCount: values.topics.length,
};
},
});
2. useActionForm
Это тонкая обертка над useActionState, которая отдает удобный API для полей, ошибок и pending-состояния.
"use client"; import { useActionForm } from "typed-form-actions/react"; import { subscribeAction } from "./actions"; export function NewsletterForm() { const form = useActionForm(subscribeAction); return ( Email <input {...form.getInputProps("email", { id: "email", type: "email" })} /> {form.getFieldError("email") ? ( <p id={form.getFieldErrorId("email")}> {form.getFieldError("email")} </p> ) : null} <fieldset> <legend>Topics</legend> <label> <input {...form.getInputProps("topics", { type: "checkbox", value: "react", })} /> React </label> <label> <input {...form.getInputProps("topics", { type: "checkbox", value: "next", })} /> Next.js </label> </fieldset> {form.formError ? <p>{form.formError}</p> : null} <button disabled={form.isPending} type="submit"> {form.isPending ? "Saving..." : "Subscribe"} </button> </form> ); }
Смысл здесь в том, что форма остается обычной формой, но у нас появляется единый typed-слой между HTML, FormData, zod и React state.
Что библиотека делает под капотом
Для меня самым интересным местом был не React-хук, а именно преобразование payload и унификация состояния.
Парсинг FormData в нормальный объект
Браузер отправляет форму как плоский набор пар ключ-значение. Но UI и схема обычно живут в объектной структуре.
Например, такой payload:
{
"profile.name": "Ada",
"tags[]": ["react", "next"],
"links[0].href": "/docs"
}
я преобразую в:
{
profile: { name: "Ada" },
tags: ["react", "next"],
links: [{ href: "/docs" }]
}
Это позволяет использовать более естественные названия полей в форме и не городить ручной маппинг под каждую вложенную структуру.
Валидация и ошибки
После этого объект идет в zod.
Если валидация не проходит, библиотека возвращает единый state:
type FormActionState<TValues, TResult> = {
status: "idle" | "success" | "error";
values: Partial;
data: TResult | null;
fieldErrors: Record<string, string[]>;
formError: string | null;
submittedAt: number | null;
};
Мне было важно, чтобы форма всегда получала предсказуемую структуру ответа, а не разный shape в зависимости от конкретной action.
React-обертка поверх useActionState
На клиенте useActionForm делает несколько полезных вещей:
отдает isPending
позволяет взять первую ошибку поля через getFieldError
генерирует id для ошибок
автоматически подставляет defaultValue и defaultChecked
прокидывает aria-invalid и aria-describedby
То есть он не прячет механику React, а просто убирает повторяющийся boilerplate.
Почему я не взяла просто react-hook-form
Это хороший вопрос.
react-hook-form мне нравится, и я не считаю, что моя библиотека должна его “убить” или заменить. Более того, в roadmap у меня есть идея адаптера под react-hook-form.
Но конкретно в этом pet-проекте мне хотелось решить немного другую задачу:
оставить нативную <form>
использовать Server Actions
не тащить лишнюю клиентскую логику туда, где можно опереться на платформу
получить единый формат валидации и ошибок
Если у вас сложная клиентская форма с кучей интерактива, кастомных контролов и сложной логикой, то react-hook-form все еще может быть лучшим выбором.
Если же вы хотите современную форму в Next.js с акцентом на server-first подход, тут тонкий слой может оказаться удобнее.
Что уже есть в typed-form-actions
На текущем этапе библиотека умеет:
парсить FormData, URLSearchParams и plain objects
собирать вложенные структуры по путям вроде profile.name и links[0].href
поддерживать повторяющиеся поля и массивы
валидировать данные через zod
возвращать предсказуемый FormActionState
давать React API для ошибок, полей и pending-состояния
собираться как отдельный пакет
проходить тесты и CI
Где библиотека пока слабая
Мне кажется важным честно это проговорить.
Это ранняя версия, и я пока не хочу притворяться, что это battle-tested production-standard решение на все случаи жизни.
Сейчас у библиотеки есть ограничения:
нет адаптера для react-hook-form
хочется лучше продумать сложные массивы объектов
нет готовых helper’ов для optimistic UI
не покрыты все возможные сценарии с файлами и экзотическими инпутами
API еще может немного поменяться после первых реальных пользователей
Но именно в этом для меня и смысл pet-проекта: не сделать “идеально и навсегда”, а выпустить рабочую вещь, получить обратную связь и дорастить ее до зрелого состояния.
сли вам близка эта проблема, буду рада любому фидбеку:
где у вас сейчас больше всего боли в формах
как вы строите связку FormData + zod + Server Actions
нужен ли здесь адаптер под react-hook-form
каких возможностей не хватает в первую очередь
И да, если вам интересна сама библиотека, вот репозиторий.
Мне будет очень интересно узнать, решает ли она ту же боль у других разработчиков, или это был только мой частный кейс.
