zod-form-action
v0.1.3
Published
A tiny, zero-dependency helper for wiring Zod (or any schema with safeParse) into React's useActionState — typed field errors, no boilerplate.
Maintainers
Readme
zod-form-action
Validate React 19 useActionState forms with Zod — typed field errors,
zero boilerplate, zero runtime dependencies.
If you're wiring Zod validation into useActionState for a Next.js
Server Action (or any React 19 form action) and tired of copy-pasting the
same safeParse → flatten().fieldErrors boilerplate into every action —
this is that boilerplate, extracted into one typed function.
npm install zod-form-actionTable of contents
- Live demo
- The problem
- Quick start
- Using with React Hook Form
- API reference
- Works with more than Zod
- zod-form-action vs next-safe-action vs React Hook Form
- FAQ
The problem
Every "React 19 form validation with Zod" tutorial has you write the same thing by hand, in every Server Action:
// what everyone writes, every single time
"use server";
export async function subscribe(prevState: State, formData: FormData) {
const raw = Object.fromEntries(formData.entries());
const parsed = schema.safeParse(raw);
if (!parsed.success) {
return { status: "invalid", errors: parsed.error.flatten().fieldErrors };
}
// ...now do the actual work, and remember to handle thrown errors too
}It's not hard, it's just repetitive — and easy to get subtly wrong (mismatched
state shapes, forgetting to catch a thrown error, inconsistent field-error
typing). zod-form-action turns it into one function call.
Quick start
1. Define the action (works in a Next.js Server Action, or any
useActionState-compatible setup):
"use server";
import { z } from "zod";
import { zodAction } from "zod-form-action";
const schema = z.object({
email: z.string().email("Enter a valid email"),
});
export const subscribe = zodAction(schema, async (data) => {
await db.subscribers.create(data);
return { status: "success", data };
});2. Use it with useActionState in a client component:
"use client";
import { useActionState } from "react";
import { subscribe } from "./actions";
import { initialActionState, fieldError } from "zod-form-action";
export function SubscribeForm() {
const [state, action, pending] = useActionState(
subscribe,
initialActionState,
);
return (
<form action={action}>
<input name="email" />
{fieldError(state, "email") && (
<p role="alert">{fieldError(state, "email")}</p>
)}
<button disabled={pending}>Subscribe</button>
</form>
);
}That's the entire API surface for the common case. state.status is a
discriminated union — "idle" | "invalid" | "error" | "success" — so
TypeScript narrows automatically: state.errors only exists when
status === "invalid", state.data only exists when status === "success".
More complete examples — multi-field forms, handling thrown errors like
"email already exists", and using a non-Zod validator — are in
/examples.
Using with React Hook Form
If you're using useActionState directly (as above), there's nothing else
to do. But if you're combining it with React Hook Form — for
client-side validation UX, formState.isDirty, etc. — there are two ways
to keep RHF in sync with the server response.
Option A — RHF's built-in errors option (recommended, since v7.49.0).
useForm() has an errors prop that reactively syncs whenever the object
you pass it changes — no useEffect needed:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useActionState } from "react";
import { initialActionState } from "zod-form-action";
import { signup } from "./actions";
export function SignupForm() {
const [state, action, pending] = useActionState(signup, initialActionState);
const form = useForm({
resolver: zodResolver(schema),
errors:
state.status === "invalid"
? Object.fromEntries(
Object.entries(state.errors).map(([field, msgs]) => [
field,
{ type: "server", message: msgs?.[0] },
]),
)
: undefined,
mode: "onBlur", // required — onSubmit won't react to the errors prop
});
return (
<form
onSubmit={form.handleSubmit((data) => {
const formData = new FormData();
formData.set("email", data.email);
action(formData);
})}
>
<input {...form.register("email")} />
{form.formState.errors.email && (
<p>{form.formState.errors.email.message}</p>
)}
<button disabled={pending}>Sign up</button>
</form>
);
}This is the leaner option — no extra import from this package needed.
See RHF's useForm docs for
the errors option (search the page for errors).
Option B — syncActionStateToForm helper. If you'd rather not
hand-write the field-error mapping above, or you also need to handle a
thrown top-level error (status: "error") landing on RHF's root error
key, this package includes a small helper for it:
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useActionState, useEffect } from "react";
import { syncActionStateToForm, initialActionState } from "zod-form-action";
import { signup } from "./actions";
export function SignupForm() {
const form = useForm({ resolver: zodResolver(schema) });
const [state, action, pending] = useActionState(signup, initialActionState);
useEffect(() => {
syncActionStateToForm(form, state);
}, [state]);
return (
<form
onSubmit={form.handleSubmit((data) => {
const formData = new FormData();
formData.set("email", data.email);
action(formData);
})}
>
<input {...form.register("email")} />
{form.formState.errors.email && (
<p>{form.formState.errors.email.message}</p>
)}
<button disabled={pending}>Sign up</button>
</form>
);
}Field errors from status: "invalid" map onto the matching RHF field;
a thrown error (status: "error") lands on RHF's root error key — which
Option A's errors prop alone doesn't handle, since it's scoped to field
errors only. No hard dependency on react-hook-form — like zodAction,
this is structurally typed against the small part of useForm()'s return
value it actually needs. Full example:
/examples/with-react-hook-form.
API reference
zodAction(schema, handler)
Wraps a schema and a handler into a useActionState-compatible action
function.
- Parses
FormDataand runsschema.safeParseautomatically - The handler only runs if validation passes, and receives the parsed,
typed data — never raw
FormData - Errors thrown inside the handler are caught automatically and returned as
{ status: "error", message }— notry/catchneeded in your action
initialActionState
A ready-made { status: "idle" } value for the second argument to
useActionState.
fieldError(state, field)
Returns the first error message for a given field, or undefined. Safe to
call regardless of the current status.
isSuccess(state)
A type guard that narrows state to the "success" variant.
syncActionStateToForm(form, state)
Syncs a zodAction result onto a React Hook Form instance — see
Using with React Hook Form. Call it inside
a useEffect keyed on state.
Works with more than Zod
The package has no hard dependency on zod — it's structurally typed
against a safeParse(data) => { success, data } | { success, error } shape
with an error.flatten().fieldErrors method, which is Zod's specific
safeParse contract (v3 and v4 both satisfy it). Any other validator that
matches this exact shape works too, with no changes needed. See
/examples/non-zod-validator.
Note: this is Zod's shape specifically, not a universal standard.
Some popular validators use a different result shape and are not
drop-in compatible today — for example, Valibot's safeParse returns
{ success, output } / { success, issues } rather than
{ success, data } / { success, error }, so it needs a small adapter
(or direct library support, which isn't implemented yet) rather than
working out of the box.
zod-form-action vs next-safe-action vs React Hook Form
| | zod-form-action | next-safe-action | React Hook Form + Zod |
| ---------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Scope | Just useActionState + Zod wiring | Full type-safe action layer (client callers, middleware, input/output validation) | Full client-side form state management |
| Bundle size | ~0.6 KB | Larger — a complete framework | Larger — a complete framework |
| Client-side wrapper required | No | Yes (useAction hook) | Yes (useForm hook) |
| Best for | You already use useActionState and just want clean field errors | You want a typed RPC-style layer over Server Actions | Client-heavy forms, complex client validation, uncontrolled inputs at scale |
If you want middleware, procedures, or a typed client caller, use
next-safe-action — it's excellent at that.
zod-form-action is for the narrower, very common case: you already have
useActionState and a Zod schema, and just want the wiring between them to
stop being copy-pasted.
FAQ
Does this work with the Next.js App Router?
Yes — it's designed around Server Actions and useActionState, which is
how Next.js App Router forms typically validate data server-side.
Does this work with React Router v7 / Remix?
Yes, anywhere useActionState is available (React 19+).
Can I use Valibot or ArkType instead of Zod?
Not out of the box today — Valibot's safeParse returns a different
shape ({ success, output } / { success, issues }) than the
{ success, data } / { success, error } shape this package expects, so
it isn't drop-in compatible yet. Any validator that matches Zod's exact
safeParse contract (including error.flatten().fieldErrors) does work
with no changes. See Works with more than Zod.
How is this different from useFormState?
useFormState was the experimental predecessor to useActionState in
React canary builds; useActionState is the stable React 19 API. This
package targets useActionState.
License
MIT
