I like the direction React and Next.js are heading: native forms, Server Actions, less client-side JavaScript, more progressive enhancement.
It's a beautiful concept.
But as soon as a form becomes more complex than a single email field, it becomes clear that a lot of the same old glue code appears around it:
get values from FormData
collect arrays and nested fields
validate everything via zod
turn zod errors into a convenient object for the UI
return a predictable state for useActionState
manually writing defaultValue, defaultChecked, aria-invalid, aria-describedby again
repeat this in every subsequent form
At some point, I realized I wasn't writing business logic, but rather the same infrastructure around forms.
That's how my pet project: typed-form-actions was born.
This is a small library for React and Next.js that ties together FormData, zod, and useActionState so that forms remain native but are also typed and easy to work with.
What's the pain?
If you take Next.js App Router + Server Actions + zod, the typical scenario looks like this:
The user submits the form.
FormData arrives in the Server Action.
We extract the fields manually.
We assemble an object from the flat structure.
We validate it using zod.
We return errors for the fields.
On the client, we manually tie all this together with useActionState again.
So, the stack seems modern, but the amount of routine work is still very noticeable.
Here's a very typical code snippet without an additional layer of abstraction:
"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(),
};
}
}
This function itself isn't scary.
The problem is that it appears again, and again, and again. And if the form contains arrays, checkboxes, or nested fields like profile.name and links[0].href, the code becomes even longer.
What I wanted from the library
I didn't want to create another gigantic form framework.
On the contrary, I wanted a very narrow library with a clear purpose:
not break native HTML forms
not move everything into client-only logic
work well with Server Actions
work well with zod
обеспечить стабильное состояние действия
не заставляют вас вручную собирать одно и то же в каждом проекте
Итак, идея заключалась не в замене всех существующих решений для форм, а в решении очень специфической проблемы, связанной с современными формами React / Next.js. Каков конечный результат?
В конечном счете, API библиотеки построен на основе двух принципов:
createFormAction
useActionForm
1. createFormAction
Эта функция принимает схему Zod и обработчик и возвращает функцию, которую можно использовать в качестве действия для формы.
import { createFormAction } from "typed-form-actions";
import { z } from "zod";
const newsletterSchema = z.object({
email: z.string().email("Введите действительный адрес электронной почты."),
темы: z.array(z.string()).min(1, "Выберите хотя бы одну тему."),
маркетинг: z
.preprocess((value) => value === "on", z.boolean())
.default(false),
});
export const subscribeAction = createFormAction({
схема: newsletterSchema,
асинхронный обработчик(значений) {
возвращаться {
сообщение: Подписано ${values.email},
topicsCount: values.topics.length,
};
},
});
2. useActionForm
Это простая обертка над useActionState, предоставляющая удобный API для полей, ошибок и состояния ожидания.
"использовать клиент";
import { useActionForm } from "typed-form-actions/react";
import { subscribeAction } from "./actions";
export function NewsletterForm() {
const form = useActionForm(subscribeAction);
возвращаться (
Электронная почта <input {...form.getInputProps("email", { id: "email", type: "email" })} />
{form.getFieldError("email") ? (
<p id={form.getFieldErrorId("email")}>
{form.getFieldError("email")}
</p>
): нулевой}
<fieldset>
<legend>Темы</legend>
It's a beautiful concept.
But as soon as a form becomes more complex than a single email field, it becomes clear that a lot of the same old glue code appears around it:
get values from FormData
collect arrays and nested fields
validate everything via zod
turn zod errors into a convenient object for the UI
return a predictable state for useActionState
manually writing defaultValue, defaultChecked, aria-invalid, aria-describedby again
repeat this in every subsequent form
At some point, I realized I wasn't writing business logic, but rather the same infrastructure around forms.
That's how my pet project: typed-form-actions was born.
This is a small library for React and Next.js that ties together FormData, zod, and useActionState so that forms remain native but are also typed and easy to work with.
What's the pain?
If you take Next.js App Router + Server Actions + zod, the typical scenario looks like this:
The user submits the form.
FormData arrives in the Server Action.
We extract the fields manually.
We assemble an object from the flat structure.
We validate it using zod.
We return errors for the fields.
On the client, we manually tie all this together with useActionState again.
So, the stack seems modern, but the amount of routine work is still very noticeable.
Here's a very typical code snippet without an additional layer of abstraction:
"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(),
};
}
}
This function itself isn't scary.
The problem is that it appears again, and again, and again. And if the form contains arrays, checkboxes, or nested fields like profile.name and links[0].href, the code becomes even longer.
What I wanted from the library
I didn't want to create another gigantic form framework.
On the contrary, I wanted a very narrow library with a clear purpose:
not break native HTML forms
not move everything into client-only logic
work well with Server Actions
work well with zod
обеспечить стабильное состояние действия
не заставляют вас вручную собирать одно и то же в каждом проекте
Итак, идея заключалась не в замене всех существующих решений для форм, а в решении очень специфической проблемы, связанной с современными формами React / Next.js. Каков конечный результат?
В конечном счете, API библиотеки построен на основе двух принципов:
createFormAction
useActionForm
1. createFormAction
Эта функция принимает схему Zod и обработчик и возвращает функцию, которую можно использовать в качестве действия для формы.
import { createFormAction } from "typed-form-actions";
import { z } from "zod";
const newsletterSchema = z.object({
email: z.string().email("Введите действительный адрес электронной почты."),
темы: z.array(z.string()).min(1, "Выберите хотя бы одну тему."),
маркетинг: z
.preprocess((value) => value === "on", z.boolean())
.default(false),
});
export const subscribeAction = createFormAction({
схема: newsletterSchema,
асинхронный обработчик(значений) {
возвращаться {
сообщение: Подписано ${values.email},
topicsCount: values.topics.length,
};
},
});
2. useActionForm
Это простая обертка над useActionState, предоставляющая удобный API для полей, ошибок и состояния ожидания.
"использовать клиент";
import { useActionForm } from "typed-form-actions/react";
import { subscribeAction } from "./actions";
export function NewsletterForm() {
const form = useActionForm(subscribeAction);
возвращаться (
Электронная почта <input {...form.getInputProps("email", { id: "email", type: "email" })} />
{form.getFieldError("email") ? (
<p id={form.getFieldErrorId("email")}>
{form.getFieldError("email")}
</p>
): нулевой}
<fieldset>
<legend>Темы</legend>