Skip to content

gabpaesschulz/hookform-action

Repository files navigation

⚑ hookform-action

The missing layer between React Hook Form and your server.
Typed submit flows, automatic Zod error mapping, optimistic UI with rollback, persistence, and DevTools β€”
for Next.js Server Actions, Vite, Remix, Astro, or any React app.

πŸ“š Documentation Β Β·Β  Quick Start Β Β·Β  Choose Your Path Β Β·Β  Examples Β Β·Β  FAQ Β Β·Β  API Reference Β Β·Β  Packages

npm version npm downloads CI license


The Problem

React Hook Form handles form state beautifully, but the moment you connect it to a server β€” a Next.js Server Action, a REST endpoint, a Remix action β€” you end up writing the same boilerplate every single time:

  • Manually serialize form values to FormData or JSON
  • Wire useTransition or useFormState to track pending state
  • Parse Zod errors from the server response and map them back to individual fields
  • Handle prevState for progressive enhancement
  • Roll back UI state on failure

That is hundreds of lines of plumbing that has nothing to do with your actual business logic.

hookform-action gives you one typed hook that handles all of it.


Why hookform-action?

Concern Without hookform-action With hookform-action
Type safety Manual casts from FormData Full inference from your Zod schema
Error mapping Parse JSON β†’ iterate fields β†’ setError() Automatic, zero config
Pending state useTransition + manual boolean formState.isPending
Optimistic UI Custom useOptimistic wiring optimisticKey + optimisticData
Client validation Duplicate schema setup Auto-detected from withZod
Multi-step persistence Roll your own sessionStorage persistKey + persistDebounce
Debugging console.log everywhere <FormDevTool /> panel

Before & After

Without hookform-action

// ❌ Manual wiring β€” ~60 lines to do what one hook does
"use client";
import { useForm } from "react-hook-form";
import { useTransition } from "react";

export function LoginForm() {
  const [isPending, startTransition] = useTransition();
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors },
  } = useForm();

  const onSubmit = (values) => {
    startTransition(async () => {
      const formData = new FormData();
      Object.entries(values).forEach(([k, v]) => formData.append(k, String(v)));

      const result = await loginAction(null, formData);

      if (!result.success && result.errors) {
        Object.entries(result.errors).forEach(([field, messages]) => {
          setError(field, { message: messages[0] });
        });
      }
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      <button disabled={isPending}>Sign in</button>
    </form>
  );
}

With hookform-action

// βœ… One hook, fully typed, zero boilerplate
"use client";
import { useActionForm } from "hookform-action";
import { loginAction } from "./actions";

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isPending },
  } = useActionForm(loginAction, { validationMode: "onChange" });

  return (
    <form onSubmit={handleSubmit()}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}
      <button disabled={isPending}>Sign in</button>
    </form>
  );
}

Packages

hookform-action is a monorepo. Install the adapter that matches your stack.

Package Install Description
hookform-action npm i hookform-action Next.js adapter β€” Server Actions, FormData, prevState
hookform-action-standalone npm i hookform-action-standalone Standalone adapter β€” fetch, axios, Remix, Vite, Astro
hookform-action-core internal Framework-agnostic core β€” useActionFormCore, withZod, persistence
hookform-action-devtools npm i hookform-action-devtools Floating debug panel β€” form state, submission history

Zod and react-hook-form are peer dependencies. Install them alongside any adapter:

npm install hookform-action react-hook-form zod

Choose Your Path

Pick the shortest adoption route for your stack:

You are using Install Start here
Next.js App Router + Server Actions npm i hookform-action react-hook-form zod Quick Start - Next.js
Vite / Remix / Astro / SPA npm i hookform-action-standalone react-hook-form zod Quick Start - Standalone
Custom adapter / framework integration npm i hookform-action-core react-hook-form zod How It Works

Examples that show real value

These are the examples that convert fastest for new users:

  • Login / registration with server validation + field error mapping
  • Client-side validation with a shared schema (no duplication)
  • Optimistic UI with rollback on action failure
  • Multi-step wizard with draft persistence

Live docs pages:


What you stop writing

  • ❌ Manual FormData β†’ typed object conversion
  • ❌ .flatten().fieldErrors β†’ setError() mapping
  • ❌ Duplicate Zod passes for client-side validation
  • ❌ useTransition / startTransition wiring
  • ❌ useOptimistic setup and rollback logic
  • ❌ sessionStorage wiring for multi-step wizards

What you get

  • βœ… Full type inference β€” types flow from your Zod schema through withZod into the hook with no manual generics
  • βœ… Auto error mapping β€” server-side Zod errors (flatten().fieldErrors) are automatically applied to RHF fields
  • βœ… Client-side validation β€” real-time validation using the same schema, with onChange, onBlur, or onSubmit modes
  • βœ… Optimistic UI β€” native useOptimistic (React 19) with automatic rollback and a React 18 fallback
  • βœ… Wizard persistence β€” multi-step form state survives page refreshes via sessionStorage with debounce
  • βœ… Headless <Form> β€” optional context provider that distributes form state to any child component
  • βœ… DevTools β€” floating panel with live state, submission history, and debug actions; zero CSS dependencies
  • βœ… Plugin system β€” lifecycle hooks (onBeforeSubmit, onSuccess, onError, onMount) for custom integrations
  • βœ… Tiny footprint β€” ESM + CJS, tree-shakeable, only peer deps; no runtime bloat
  • βœ… 81+ tests β€” Vitest + React Testing Library

Quick Start β€” Next.js

1. Install

npm install hookform-action react-hook-form zod

2. Create a Server Action

// app/login/actions.ts
"use server";
import { z } from "zod";
import { withZod } from "hookform-action-core/with-zod";

const schema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "At least 8 characters"),
});

export const loginAction = withZod(schema, async (data) => {
  // `data` is fully typed as { email: string; password: string }
  const user = await db.authenticate(data.email, data.password);
  if (!user) return { success: false, errors: { email: ["Invalid credentials"] } };
  return { success: true };
});

withZod validates on the server, maps Zod errors to the flat { errors: Record<string, string[]> } shape, and attaches the schema to the action so the hook can auto-detect it for client-side validation.

3. Use the Hook

// app/login/login-form.tsx
"use client";
import { useActionForm } from "hookform-action";
import { loginAction } from "./actions";

export function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isPending, isSubmitSuccessful },
  } = useActionForm(loginAction, {
    defaultValues: { email: "", password: "" },
    validationMode: "onChange", // schema auto-detected from withZod
    onSuccess: () => redirect("/dashboard"),
  });

  return (
    <form onSubmit={handleSubmit()}>
      <div>
        <input {...register("email")} placeholder="Email" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <div>
        <input {...register("password")} type="password" placeholder="Password" />
        {errors.password && <p>{errors.password.message}</p>}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Signing in…" : "Sign In"}
      </button>
    </form>
  );
}

Quick Start β€” Standalone (Vite, Remix, Astro)

npm install hookform-action-standalone react-hook-form zod
// components/contact-form.tsx
import { useActionForm } from "hookform-action-standalone";
import { z } from "zod";

const schema = z.object({
  email: z.string().email(),
  message: z.string().min(10),
});

export function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isPending, isSubmitSuccessful },
  } = useActionForm({
    submit: async (data) => {
      const res = await fetch("/api/contact", {
        method: "POST",
        body: JSON.stringify(data),
        headers: { "Content-Type": "application/json" },
      });
      return res.json();
    },
    schema,
    validationMode: "onBlur",
    defaultValues: { email: "", message: "" },
  });

  if (isSubmitSuccessful) return <p>Message sent!</p>;

  return (
    <form onSubmit={handleSubmit()}>
      <input {...register("email")} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}

      <textarea {...register("message")} placeholder="Your message" />
      {errors.message && <p>{errors.message.message}</p>}

      <button type="submit" disabled={isPending}>
        {isPending ? "Sending…" : "Send"}
      </button>
    </form>
  );
}

The standalone adapter is identical to the Next.js API β€” the only difference is that you pass a submit function instead of a Server Action.


Mental Model

Your Zod schema
      β”‚
      β–Ό
 withZod(schema, handler)          ← server-side: validates input, types handler
      β”‚
      β”‚  returns { success, errors } or handler's return value
      β–Ό
 useActionForm(action, options)    ← client-side: bridges RHF ↔ your action
      β”‚
      β”œβ”€β”€ manages form state (RHF)
      β”œβ”€β”€ runs client-side Zod validation (onChange / onBlur / onSubmit)
      β”œβ”€β”€ serializes values β†’ calls your action
      β”œβ”€β”€ maps server errors β†’ RHF fields (auto)
      β”œβ”€β”€ drives isPending via useTransition
      β”œβ”€β”€ updates optimistic state (React 19 useOptimistic)
      └── persists to sessionStorage (wizard mode)

The key insight: your schema is the single source of truth. Write it once, attach it with withZod, and the hook picks it up automatically β€” no duplication, no manual error parsing, no type gymnastics.


When to Use / When Not to Use

βœ… Good fit

  • Forms that submit to a server and need to display field-level errors
  • Next.js App Router with Server Actions
  • Vite / Remix / Astro apps submitting to a REST or RPC endpoint
  • Multi-step onboarding wizards where state must survive navigation
  • Any form that benefits from instant optimistic feedback

⚠️ Might be overkill

  • Simple single-field inline edits with no validation requirements
  • Forms that only run on the client with no server round-trip
  • Projects that already have a custom RHF + server error pipeline they are happy with

❌ Not designed for

  • Non-React environments
  • Uncontrolled <form action="..."> submissions without JavaScript (use the formAction prop for progressive enhancement instead)

Common Use Cases

Login / Registration
const form = useActionForm(registerAction, {
  defaultValues: { email: "", password: "", confirmPassword: "" },
  validationMode: "onBlur",
  onSuccess: () => router.push("/onboarding"),
});
Optimistic List Item Update
const { register, handleSubmit, optimistic } = useActionForm(updateTodoAction, {
  defaultValues: { title: "" },
  optimisticKey: `todo-${todo.id}`,
  optimisticInitial: todo,
  optimisticData: (current, values) => ({ ...current, title: values.title }),
});

// Render optimistic.data immediately β€” no waiting for the server
return <span>{optimistic.data.title}</span>;
Multi-Step Wizard
// Step 1 β€” values persist to sessionStorage automatically
const step1 = useActionForm(noopAction, {
  defaultValues: { name: "", company: "" },
  persistKey: "onboarding",
});

// Step 2 β€” resumes from persisted state on reload
const step2 = useActionForm(submitOnboardingAction, {
  defaultValues: { plan: "", billing: "" },
  persistKey: "onboarding",
  onSuccess: () => step1.clearPersistedData(),
});
Custom Error Handling
const form = useActionForm(myAction, {
  // Override the default Zod error mapper for non-standard API shapes
  errorMapper: (result) => result?.validationErrors ?? null,
  onError: (err) => toast.error(err instanceof Error ? err.message : "Something went wrong"),
});
DevTools
import { FormDevTool } from "hookform-action-devtools";

export function MyPage() {
  const form = useActionForm(myAction, {
    defaultValues: {
      /* ... */
    },
  });

  return (
    <>
      <MyForm form={form} />
      {process.env.NODE_ENV === "development" && <FormDevTool control={form.control} position="bottom-right" />}
    </>
  );
}

The DevTools panel shows:

  • State tab β€” live field values, errors, and submission status
  • History tab β€” every submission with its payload, response, and duration
  • Actions tab β€” manual debug triggers and aggregate stats

How It Works

hookform-action is built in three layers.

Layer 1 β€” withZod (server)

A thin wrapper that runs Zod validation before your handler. On failure it returns { success: false, errors: Record<string, string[]> } β€” the exact shape the hook expects. On success it calls your handler with fully typed data and attaches the schema as action.__schema so the hook can reuse it on the client without duplication.

Layer 2 β€” useActionFormCore (framework-agnostic)

The engine. It accepts a single submit: (data: T) => Promise<TResult> function and handles:

  • Initialising React Hook Form with persisted or default values
  • Running client-side schema validation before submission
  • Calling submit, setting isPending via useTransition
  • Parsing the result with errorMapper and applying errors to RHF fields
  • Updating the useOptimistic state (React 19) or the fallback useState (React 18)
  • Debounce-writing form values to sessionStorage when persistKey is set
  • Running plugin lifecycle callbacks

Layer 3 β€” Adapters (hookform-action / hookform-action-standalone)

Thin wrappers around the core. The Next.js adapter handles FormData serialization and prevState tracking. The standalone adapter forwards the user's submit function directly. Both expose an identical public API.


API Reference

useActionForm(action, options?) β€” Next.js

import { useActionForm } from "hookform-action";

Options

Option Type Default Description
defaultValues DefaultValues<T> β€” Initial field values
mode Mode 'onSubmit' RHF internal validation mode
schema ZodSchema auto Client-side schema (auto-detected from withZod)
validationMode 'onChange' | 'onBlur' | 'onSubmit' 'onSubmit' When to run client validation
persistKey string β€” sessionStorage key for wizard persistence
persistDebounce number 300 Write debounce in ms
errorMapper (result) => FieldErrorRecord | null Zod format Custom server error extractor
onSuccess (result) => void β€” Called after a successful submission
onError (result | Error) => void β€” Called after a failed submission
optimisticKey string β€” Enables optimistic UI
optimisticInitial T β€” Initial optimistic state
optimisticData (current: T, values) => T β€” Reducer for optimistic state
plugins Plugin[] [] Lifecycle plugin array

useActionForm({ submit, ...options }) β€” Standalone

import { useActionForm } from "hookform-action-standalone";

Identical options, with one addition:

Option Type Required Description
submit (data: T) => Promise<TResult> βœ… The async function that handles submission

Return Value

Everything from RHF's useForm, plus:

Property Type Description
handleSubmit(onValid?) () => FormEventHandler Enhanced submit handler
formState.isPending boolean true while transition/request is pending
formState.isSubmitting boolean submit-in-progress flag (RHF + internal action state)
formState.isSubmitSuccessful boolean true when the last completed submit succeeded
formState.submitErrors FieldErrorRecord | null structured field errors from validation/error mapping
formState.actionResult TResult | null full result from the last completed action response
setSubmitError(field, msg) fn Manually set a field error
persist() fn Manually flush state to sessionStorage
clearPersistedData() fn Remove persisted state for this form
formAction (FormData) => void Direct <form action={...}> handler (Next.js only)
optimistic.data TOptimistic Current optimistic state
optimistic.isPending boolean true while optimistic update is unconfirmed
optimistic.rollback() fn Revert to last confirmed state

Submit Lifecycle (Cheat Sheet)

Use this mental model for action-driven forms:

  1. handleSubmit() starts the transition and async submit.
  2. isPending turns true and should drive UI locking/loading.
  3. Submission ends in one of three outcomes:
    • success: isSubmitSuccessful = true, submitErrors = null, actionResult = result
    • field errors: isSubmitSuccessful = false, submitErrors = {...}, actionResult = result
    • thrown error: isSubmitSuccessful = false, handle with onError

Which state should I use?

Goal State(s) to use
Disable submit button formState.isPending
Show loading spinner/text formState.isPending
Run success side-effects !formState.isPending && formState.isSubmitSuccessful
Render field/server validation formState.submitErrors + formState.errors
Read confirmed response payload formState.actionResult guarded by success checks

Correct pattern

const { handleSubmit, formState } = useActionForm(action, { defaultValues });
const { isPending, isSubmitSuccessful, submitErrors, actionResult } = formState;

<button disabled={isPending}>
  {isPending ? "Saving..." : "Save"}
</button>;

useEffect(() => {
  if (!isPending && isSubmitSuccessful) {
    // toast / redirect / reset
  }
}, [isPending, isSubmitSuccessful]);

Common mistakes

  • Using isSubmitting for button/loader instead of isPending
  • Treating actionResult as "success only" data
  • Triggering post-submit logic from isSubmitSuccessful without checking !isPending
  • Expecting thrown exceptions inside submitErrors (use onError for that path)

For a full timeline and examples, see apps/docs/app/submit-lifecycle/page.tsx.

withZod(schema, handler)

import { withZod } from "hookform-action-core/with-zod";
Argument Type Description
schema ZodSchema Zod schema to validate against
handler (data: z.infer<typeof schema>) => Promise<TResult> Called with typed, validated data

Returns a Server Action with __schema attached for client-side auto-detection.


Migration Guide

v3 β†’ v4

No breaking changes. All existing imports and options remain identical.

What's new in v4:

  • Package versions consolidated under the v4 line
  • Documentation IA updated for faster adoption and support deflection
  • Expanded examples + recipes + troubleshooting guides
  • Canonical API naming standardized (validationMode, optimisticData, submitErrors, actionResult)

Legacy aliases are still supported for backward compatibility:

Legacy name Canonical name
clientValidation validationMode
optimisticReducer / optimisticDefault optimisticData / optimisticInitial
formState.serverErrors / formState.lastResult formState.submitErrors / formState.actionResult
setServerError setSubmitError
clearPersisted clearPersistedData

v2 β†’ v3

No breaking changes. All existing imports and options remain identical.

What's new in v3:

  • Use hookform-action-standalone for Vite, Remix, and Astro apps
  • Add hookform-action-devtools for a floating debug panel in development
  • Use useActionFormCore directly to build custom adapters for any framework
  • Add plugins to hook into the form submission lifecycle

v1 β†’ v2

1. Prefer isPending over isSubmitting for button states

- <button disabled={formState.isSubmitting}>Submit</button>
+ <button disabled={formState.isPending}>Submit</button>

isSubmitting remains available, but isPending reflects the useTransition state and is more accurate during concurrent React renders.

2. Enable client-side validation (optional)

  const form = useActionForm(myAction, {
    defaultValues: { email: "" },
+   schema: mySchema,
+   validationMode: "onChange",
  });

If your action was created with withZod, the schema is auto-detected β€” you only need to set validationMode.


Requirements

Dependency Minimum Notes
React 18.0 React 19 recommended for native useOptimistic
React Hook Form 7.50 β€”
Zod 3.22 Peer dependency; optional if not using withZod
Next.js 14.0 Required only for hookform-action (Next.js adapter)

Compatibility Matrix

Package Current line Purpose
hookform-action 4.x Next.js adapter (Server Actions)
hookform-action-standalone 4.x Non-Next React apps
hookform-action-core 4.x Framework-agnostic core
hookform-action-devtools 4.x Optional dev debug panel

FAQ / Troubleshooting

For support-heavy questions and symptom-based debugging:

Most common adoption blockers covered there:

  • Action runs but field errors do not render
  • isPending vs isSubmitting confusion
  • persistKey restoring stale drafts
  • Optimistic state not appearing or not rolling back
  • File upload + FormData integration pitfalls

Development

# Clone and install dependencies
git clone https://github.com/gabpaesschulz/hookform-action.git
cd hookform-action
pnpm install

# Start the dev server (core + docs)
pnpm dev

# Run all tests
pnpm test

# Build all packages
pnpm build

# Create a changeset before opening a PR
pnpm changeset

The repository is a Turborepo monorepo with pnpm workspaces. Each package under packages/ is independently versioned and published.


License

MIT Β© hookform-action contributors

About

React Hook Form + Next.js Server Actions = πŸ’˜. Zero-config, tipagem inferida, optimistic UI, React 19 ready.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages