npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

zod-form-action

Validate React 19 useActionState forms with Zod — typed field errors, zero boilerplate, zero runtime dependencies.

npm version npm downloads bundle size license Open in CodeSandbox

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 safeParseflatten().fieldErrors boilerplate into every action — this is that boilerplate, extracted into one typed function.

Try the live demo →

npm install zod-form-action

Table of contents

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 FormData and runs schema.safeParse automatically
  • 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 } — no try/catch needed 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