-
🌍
-
Framework-Agnostic Core
-
- The core logic no longer depends on Next.js. Use{' '}
- hookform-action-standalone with Vite, Remix, Astro, or any React app.
+
+
+ Manual Wiring vs hookform-action
+
+
+ Same primitives, less glue code. You keep RHF and Zod control, but remove repeated
+ submit-flow plumbing.
+
+
+
+
+
+ Concern
+ Manual
+ hookform-action
+
+
+
+ {comparisonRows.map((row) => (
+
+ {row.concern}
+ {row.manual}
+ {row.withHookformAction}
+
+ ))}
+
+
+
+
+
+
+
+ Before (manual)
+
+
{`const result = await action(values);
+if (result.errors) {
+ for (const [field, messages] of Object.entries(result.errors)) {
+ setError(field as keyof Fields, { message: messages[0] });
+ }
+}`}
+
-
-
🔍
-
DevTools Panel
-
- hookform-action-devtools — a floating debug panel showing form state,
- submission history, and debug actions. Inspired by TanStack Query DevTools.
+
+
+ After (hookform-action)
+
+
{`const { handleSubmit, formState: { errors, isPending } } =
+ useActionForm(action, { validationMode: 'onChange' });
+
+ ;`}
+
-
-
🧩
-
Plugin System (Internal)
-
- An internal plugin architecture with lifecycle hooks (onBeforeSubmit,
- onSuccess, onError, onMount) powering future
- extensibility.
-
+
+
+
+
+
-1 layer
+
single integration API for submit flows
-
-
🔄
-
Zero Breaking Changes
-
- Existing hookform-action v2 imports work exactly the same. The Next.js
- adapter is 100% backward-compatible.
-
+
+
+1 schema
+
server and client stay aligned
+
+
+
-N bugs
+
less custom wiring to maintain per form
- {/* Architecture diagram */}
-
- Architecture
-
-
{`┌─────────────────────────────────────────────┐
-│ hookform-action-core (core) │
-│ useActionFormCore · withZod · Form · persist │
-├────────────────────┬────────────────────────┤
-│ hookform-action │ hookform-action │
-│ (Next.js) │ -standalone │
-│ (Server Actions) │ (fetch / REST / gRPC) │
-└────────────────────┴────────────────────────┘
- ┌──────────────────────┐
- │ hookform-action │
- │ -devtools │
- │ (FormDevTool panel) │
- └──────────────────────┘`}
+
+ Feature Clusters
+
+ Built on top of RHF. No lock-in, only less repetition.
+
+
+ {featureClusters.map((cluster) => (
+
+
+ {cluster.label}
+
+ {cluster.title}
+
+ {cluster.points.map((point) => (
+
+ •
+ {point}
+
+ ))}
+
+
+ ))}
- {/* Features */}
-
- {[
- {
- icon: '🔒',
- title: 'Type-Safe',
- desc: 'Full TypeScript inference from your action — zero manual typing.',
- },
- {
- icon: '⚡',
- title: 'Auto Error Mapping',
- desc: 'Zod validation errors mapped to RHF fields automatically.',
- },
- {
- icon: '💾',
- title: 'Wizard Persistence',
- desc: 'Multi-step forms with sessionStorage persistence, debounced and SSR-safe.',
- },
- {
- icon: '🚀',
- title: 'Optimistic UI',
- desc: 'Native useOptimistic integration. Instant UI updates with automatic rollback.',
- },
- {
- icon: '📦',
- title: 'Tiny Bundle',
- desc: 'ESM + CJS via tsup. Tree-shakeable. React, RHF, and Zod are peer deps.',
- },
- {
- icon: '🧪',
- title: 'Fully Tested',
- desc: '81+ tests covering core, adapters, plugins, persistence, and optimistic UI.',
- },
- ].map((f) => (
-
-
{f.icon}
-
{f.title}
-
{f.desc}
+
+
+
+
Examples You Can Copy
+
+ Use these as your onboarding path: beginner to advanced submit flows.
+
- ))}
+
+ Browse all recipes
+
+
+
+ {examples.map((example) => (
+
+
+
+ {example.stack}
+
+
+ {example.level}
+
+
+ {example.title}
+ {example.description}
+
+ {example.cta} →
+
+
+ ))}
+
- {/* Usage – Next.js */}
-
- Usage — Next.js
-
- Works exactly like v2. No changes required.
-
-
-
{`// actions.ts — Server Action
-'use server'
-import { z } from 'zod'
-import { withZod } from 'hookform-action-core/with-zod'
-
-const schema = z.object({
- email: z.string().email(),
- password: z.string().min(8),
-})
-
-export const loginAction = withZod(schema, async (data) => {
- return { success: true }
-})
-
-// LoginForm.tsx — Client Component
-'use client'
-import { useActionForm } from 'hookform-action'
-import { loginAction } from './actions'
-
-export function LoginForm() {
- const { register, handleSubmit, formState: { errors, isPending } } =
- useActionForm(loginAction, {
- defaultValues: { email: '', password: '' },
- validationMode: 'onChange',
- })
+
+
+ Mental Model
+ One schema, one action, one form hook.
+
+
+ 1. Define schema and server handler with withZod.
+
+
+ 2. Pass action (or submit function) into useActionForm.
+
+
+ 3. Render standard RHF fields and call handleSubmit().
+
+
+ 4. Let the hook manage pending, error mapping, optimistic state, and persistence.
+
+
+
+
+ Architecture
+
+
{`withZod(schema, handler)
+ |
+ v
+ useActionFormCore
+ / \\
+ v v
+hookform-action hookform-action-standalone
+ \\ /
+ v v
+ hookform-action-devtools`}
+
+
+
- return (
-
- )
-}`}
-
+
+
+ Compatibility
+
+
+
+
+ Dependency
+ Minimum
+ Notes
+
+
+
+
+ React
+ 18.0+
+ React 19 recommended for native useOptimistic behavior.
+
+
+ React Hook Form
+ 7.50+
+ Required peer dependency.
+
+
+ Zod
+ 3.22+
+
+ Optional, recommended for withZod and typed validation flow.
+
+
+
+ Next.js
+ 14.0+
+ Only required for the Next.js adapter package.
+
+
+
+
+
+
+ Upgrade Notes
+
+ Current package line is 4.0.x. Before upgrading, check package changelogs
+ to confirm API notes and migration details for your adapter.
+
+
+
- {/* Usage – Standalone */}
-
- Usage — Vite / Standalone
-
- Same API, pass submit instead of a Server Action.
+
+ Maturity And Trust Signals
+
+ Production-focused signals to reduce adoption risk.
-
-
{`import { useActionForm } from 'hookform-action-standalone'
-import { z } from 'zod'
-
-const schema = z.object({
- email: z.string().email(),
- password: z.string().min(8),
-})
-
-export function LoginForm() {
- const { register, handleSubmit, formState: { errors, isPending } } =
- useActionForm({
- submit: async (data) => {
- const res = await fetch('/api/login', {
- method: 'POST',
- body: JSON.stringify(data),
- headers: { 'Content-Type': 'application/json' },
- })
- return res.json()
- },
- schema,
- validationMode: 'onChange',
- defaultValues: { email: '', password: '' },
- })
+
+ {trustSignals.map((item) => (
+
+ {item.title}
+ {item.detail}
+
+ ))}
+
+
+
- return (
-
- )
-}`}
+
+ FAQ
+
+ {faqItems.map((item) => (
+
+
+ {item.question}
+
+ {item.answer}
+
+ ))}
- {/* DevTools */}
-
- DevTools
-
- Add a floating panel to inspect form state in development.
+
+
+ Install And Ship Your First Form Today
+
+
+ Start with the adapter that matches your stack and move from setup to running form
+ quickly.
-
-
{`import { FormDevTool } from 'hookform-action-devtools'
-
-function App() {
- const form = useActionForm(/* ... */)
- return (
- <>
-
- {process.env.NODE_ENV === 'development' && (
-
- )}
- >
- )
-}`}
+
+
+ npm install hookform-action react-hook-form zod
+
+
+ npm install hookform-action-standalone react-hook-form zod
+
+
+
-
- {/* Footer */}
-
)
}
diff --git a/apps/docs/app/recipes/custom-error-mapper/page.tsx b/apps/docs/app/recipes/custom-error-mapper/page.tsx
new file mode 100644
index 0000000..d06855c
--- /dev/null
+++ b/apps/docs/app/recipes/custom-error-mapper/page.tsx
@@ -0,0 +1,329 @@
+export default function CustomErrorMapperPage() {
+ return (
+
+
+
+
+
+
+ Tier 3 · Specialized
+
+ Recipe #11
+
+
Custom Error Mapper
+
+ When your API returns errors in a format different from the default{' '}
+ {'{ errors: { field: string[] } }'}, use a custom errorMapper to
+ translate them into React Hook Form field errors automatically.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ APIs designed before this library existed — Laravel backends, Rails APIs, external REST
+ services, tRPC procedures — rarely return errors in the{' '}
+ {'{ errors: { field: string[] } }'} format that{' '}
+ defaultErrorMapper expects. Without a custom mapper, the hook cannot
+ automatically set field errors and you end up writing manual setSubmitError{' '}
+ calls all over your form components.
+
+
+ The errorMapper option is a pure function defined once per form (or shared
+ across forms hitting the same API). It receives the raw action result and returns a{' '}
+ FieldErrorRecord that the hook applies to the correct fields automatically.
+
+
+
+ {/* Full Example */}
+
+ Full Example — Laravel-style errors
+
+
+
+
+ The external API returns:
+
+
{`{
+ "message": "The given data was invalid.",
+ "errors": {
+ "email": ["The email has already been taken.", "Must be a valid email."],
+ "password": ["The password must be at least 8 characters."]
+ }
+}`}
+
+ (Laravel-style — the errors values are arrays of strings, which happens
+ to match the default format. The next example shows a stricter mismatch.)
+
+
+
+
+
+
+ Example 1 — Laravel / Rails (arrays of strings)
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action-standalone'
+import type { FieldErrorRecord } from 'hookform-action-core'
+import { submitRegistrationForm } from './api'
+
+// This API shape matches the default exactly — no custom mapper needed!
+// { errors: { field: string[] } }
+export function RegistrationForm() {
+ const { register, handleSubmit, formState: { errors, isPending } } =
+ useActionForm({
+ submit: submitRegistrationForm,
+ defaultValues: { email: '', password: '', name: '' },
+ // defaultErrorMapper handles this format automatically
+ })
+ // ...
+}`}
+
+
+
+
+
+
+
+ A non-standard API (object-per-error format):
+
+
+
{`{
+ "fieldErrors": {
+ "email": [{ "message": "Already taken", "code": "DUPLICATE" }],
+ "name": [{ "message": "Too short", "code": "MIN_LENGTH" }]
+ },
+ "globalError": "Validation failed"
+}`}
+
+
+
+
+
+ error-mapper.ts — Shared mapper for this API
+
+
+
{`import type { FieldErrorRecord } from 'hookform-action-core'
+
+interface ApiError {
+ fieldErrors?: Record>
+ globalError?: string
+}
+
+// Define outside components for stability (no re-renders)
+export function apiErrorMapper(result: unknown): FieldErrorRecord | null {
+ if (!result || typeof result !== 'object') return null
+ const r = result as ApiError
+
+ if (!r.fieldErrors) return null
+
+ const mapped: FieldErrorRecord = {}
+ for (const [field, errs] of Object.entries(r.fieldErrors)) {
+ // Extract just the message strings
+ mapped[field] = errs.map((e) => e.message)
+ }
+
+ return Object.keys(mapped).length > 0 ? mapped : null
+}`}
+
+
+
+
+
+ registration-form.tsx — Using the custom mapper
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action-standalone'
+import { apiErrorMapper } from './error-mapper'
+import { submitRegistrationForm } from './api'
+
+interface ApiResult {
+ fieldErrors?: Record>
+ globalError?: string
+ userId?: string
+}
+
+export function RegistrationForm() {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isPending, isSubmitSuccessful, actionResult },
+ } = useActionForm<{ email: string; name: string; password: string }, ApiResult>({
+ submit: submitRegistrationForm,
+ defaultValues: { email: '', name: '', password: '' },
+
+ // Plug in the custom mapper — runs after every action call
+ errorMapper: apiErrorMapper,
+
+ onError: (result) => {
+ // Still fires even with custom mapper, good for toasts
+ if (result instanceof Error) {
+ console.error('Network error:', result.message)
+ } else if (result.globalError) {
+ // toast.error(result.globalError)
+ }
+ },
+ })
+
+ return (
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
+ errorMapper: (result) => FieldErrorRecord | null
+
+
+ A pure function that receives the raw action result (typed as TResult)
+ and returns a FieldErrorRecord — a{' '}
+ Record<string, string[] | undefined>. Return null or{' '}
+ undefined when there are no errors. The hook calls setError{' '}
+ on each field automatically.
+
+
+
+
Define mapper outside the component
+
+ The errorMapper is compared by reference between renders. An inline arrow
+ function creates a new reference every time, causing the hook to re-run unnecessarily.
+ Define it at module scope or wrap with useCallback.
+
+
+
+
Global errors via actionResult
+
+ Errors that cannot be mapped to a specific field (e.g. "service
+ unavailable") should be read from formState.actionResult and
+ rendered separately — not through the field error system.
+
+
+
+
Composing with defaultErrorMapper
+
+ You can import and call defaultErrorMapper inside your custom mapper as a
+ fallback for fields that already match the default format, and only override the
+ fields that don't.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Returning an empty object {'{}'} instead of null
+
+
+ The hook checks if the returned object is truthy and has keys. An empty object{' '}
+ {'{}'} is truthy, but all its field lookups return{' '}
+ undefined. Return null or undefined{' '}
+ explicitly when there are no errors to map.
+
+
+
+
+ ⚠
+
+
+ Inline arrow function as errorMapper
+
+
+ {'errorMapper: (r) => ...'} creates a new function on every render.
+ Since the hook uses it as a dependency, this can cause performance issues. Always
+ define the mapper at module scope.
+
+
+
+
+ ⚠
+
+
+ Mapper receives the result even on success
+
+
+ errorMapper is called after every action invocation, including
+ successful ones. Always add a guard (e.g. check for the presence of an error key)
+ and return null for success responses.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/edit-server-data/page.tsx b/apps/docs/app/recipes/edit-server-data/page.tsx
new file mode 100644
index 0000000..fccf614
--- /dev/null
+++ b/apps/docs/app/recipes/edit-server-data/page.tsx
@@ -0,0 +1,290 @@
+export default function EditServerDataPage() {
+ return (
+
+
+
+
+
+
+ Tier 1 · Must-have
+
+ Recipe #4
+
+
Edit Form with Server-Loaded Data
+
+ Pre-populate a form from a Server Component, track which fields the user actually changed,
+ and revalidate the page data after a successful save.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ Edit forms are the second most common form type after login. They require loading data
+ from the server and using it as defaultValues — but there's a
+ fundamental tension: Server Components can fetch data asynchronously, while{' '}
+ useActionForm lives in a Client Component. Getting the data handoff right
+ prevents stale defaults and unintended dirty-field detection.
+
+
+ This recipe shows the canonical Server → Client pattern, how to use isDirty{' '}
+ to show the save button only when something changed, and how to use{' '}
+ router.refresh() to revalidate without a full page reload.
+
+
+
+ {/* Full Example */}
+
+ Full Example
+
+
+
+ actions.ts
+
+
+
{`'use server'
+import { z } from 'zod'
+import { withZod } from 'hookform-action-core/with-zod'
+
+const profileSchema = z.object({
+ name: z.string().min(1, 'Name is required'),
+ bio: z.string().max(160, 'Bio must be 160 characters or fewer'),
+ website: z.string().url('Enter a valid URL').optional().or(z.literal('')),
+})
+
+export const updateProfileAction = withZod(profileSchema, async (data) => {
+ // data is typed: { name: string; bio: string; website?: string }
+ await db.profiles.update({
+ where: { userId: session.userId },
+ data,
+ })
+ return { success: true }
+})`}
+
+
+
+
+
+ page.tsx — Server Component
+
+
+
{`// This is a Server Component — it can be async and access the database directly
+import { db } from '@/lib/db'
+import { EditProfileForm } from './edit-profile-form'
+
+export default async function EditProfilePage() {
+ // Fetch the current profile on the server
+ const profile = await db.profiles.findUnique({
+ where: { userId: session.userId },
+ select: { name: true, bio: true, website: true },
+ })
+
+ if (!profile) return Profile not found.
+
+ // Pass to the Client Component as a plain prop
+ return
+}`}
+
+
+
+
+
+ edit-profile-form.tsx — Client Component
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import { useRouter } from 'next/navigation'
+import { updateProfileAction } from './actions'
+
+interface EditProfileFormProps {
+ defaultValues: {
+ name: string
+ bio: string
+ website: string
+ }
+}
+
+export function EditProfileForm({ defaultValues }: EditProfileFormProps) {
+ const router = useRouter()
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isPending, isDirty, dirtyFields },
+ } = useActionForm(updateProfileAction, {
+ defaultValues,
+ onSuccess: (result) => {
+ // Re-fetch the Server Component data without a full reload
+ router.refresh()
+ // Optionally reset to the newly saved values to clear isDirty
+ reset(defaultValues)
+ },
+ })
+
+ return (
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
Server Component → Client Component data handoff
+
+ Fetch data in an async Server Component, then pass it as a plain prop to the Client
+ Component. The Client Component receives it as a stable object and uses it as{' '}
+ defaultValues. This avoids the useEffect fetch anti-pattern
+ and leverages Next.js caching.
+
+
+
+
isDirty / dirtyFields
+
+ isDirty is true when at least one field differs from its{' '}
+ defaultValue. dirtyFields is a record of which specific
+ fields changed. Use them to disable the save button when nothing has been modified and
+ to show per-field change indicators.
+
+
+
+
router.refresh()
+
+ Re-executes all Server Components on the current route without a full navigation. The
+ Server Component re-fetches the updated data and passes it as new props to the Client
+ Component. Call it in onSuccess after a successful save.
+
+
+
+
reset(newValues) after save
+
+ After a successful edit, call reset(result.data) or{' '}
+ reset(defaultValues) to update the RHF baseline. This clears{' '}
+ isDirty so the save button correctly disables again without a full
+ remount.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Unstable defaultValues causing unintended resets
+
+
+ If defaultValues is an inline object literal created on every render,
+ RHF may re-initialise and clear user edits. Always pass a stable reference — either
+ from a Server Component prop, a useMemo, or a fetched query result.
+
+
+
+
+ ⚠
+
+
+ isSubmitSuccessful stays true after save
+
+
+ For edit forms you usually want the user to stay on the page and keep editing. Avoid
+ gating the form behind isSubmitSuccessful — it will hide the form after
+ the first save. Use onSuccess for side effects only.
+
+
+
+
+ ⚠
+
+
+ Forgetting reset() after a successful save
+
+
+ Without reset(), isDirty stays true even
+ though the changes were saved. The save button remains enabled and the user may
+ accidentally re-submit.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/field-array/page.tsx b/apps/docs/app/recipes/field-array/page.tsx
new file mode 100644
index 0000000..28ad77c
--- /dev/null
+++ b/apps/docs/app/recipes/field-array/page.tsx
@@ -0,0 +1,322 @@
+export default function FieldArrayPage() {
+ return (
+
+
+
+
+
+
+ Tier 2 · Common
+
+ Recipe #8
+
+
Dynamic Fields with useFieldArray
+
+ Add and remove array items at runtime, validate each row independently, and submit the
+ full typed array to your server action.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ Dynamic lists — invoice line items, contact addresses, team members, skill tags — are one
+ of the most common patterns in real-world forms. React Hook Form's{' '}
+ useFieldArray is the standard solution, but integrating it with{' '}
+ useActionForm has nuances: the control object must come from the
+ hook, arrays don't play well with FormData actions, and per-item error
+ paths have a specific shape.
+
+
+ This recipe shows a complete address list form with add, remove, per-field errors, and a{' '}
+ withZod server action that receives and validates the full typed array.
+
+
+
+ {/* Full Example */}
+
+ Full Example — Address List
+
+
+
+ actions.ts
+
+
+
{`'use server'
+import { z } from 'zod'
+import { withZod } from 'hookform-action-core/with-zod'
+
+const addressSchema = z.object({
+ addresses: z
+ .array(
+ z.object({
+ street: z.string().min(1, 'Street is required'),
+ city: z.string().min(1, 'City is required'),
+ country: z.string().min(2, 'Select a country'),
+ })
+ )
+ .min(1, 'Add at least one address'),
+})
+
+export const saveAddressesAction = withZod(addressSchema, async (data) => {
+ // data.addresses is typed as { street: string; city: string; country: string }[]
+ await db.addresses.replaceAll(data.addresses)
+ return { success: true }
+})`}
+
+
+
+
+
+ address-form.tsx
+
+
+
{`'use client'
+import { useFieldArray } from 'react-hook-form' // from RHF, not hookform-action
+import { useActionForm } from 'hookform-action'
+import { saveAddressesAction } from './actions'
+
+type AddressValues = {
+ addresses: { street: string; city: string; country: string }[]
+}
+
+const EMPTY_ADDRESS = { street: '', city: '', country: '' }
+
+export function AddressForm() {
+ const {
+ register,
+ control, // ← pass to useFieldArray
+ handleSubmit,
+ formState: { errors, isPending, isSubmitSuccessful },
+ } = useActionForm(saveAddressesAction, {
+ defaultValues: { addresses: [EMPTY_ADDRESS] },
+ })
+
+ // useFieldArray reads and writes through the same RHF control
+ const { fields, append, remove, move } = useFieldArray({
+ control,
+ name: 'addresses',
+ })
+
+ if (isSubmitSuccessful) {
+ return Addresses saved!
+ }
+
+ return (
+
+
+ {fields.map((field, index) => (
+ // IMPORTANT: use field.id as key — not the array index
+
+
+
+ Address {index + 1}
+
+ {fields.length > 1 && (
+ remove(index)}
+ className="text-sm text-red-400 hover:text-red-300"
+ >
+ Remove
+
+ )}
+
+
+
+
+
Street
+
+ {errors.addresses?.[index]?.street && (
+
+ {errors.addresses[index].street?.message}
+
+ )}
+
+
+
+
+
City
+
+ {errors.addresses?.[index]?.city && (
+
+ {errors.addresses[index].city?.message}
+
+ )}
+
+
+
+
Country
+
+ Select…
+ United States
+ United Kingdom
+ Brazil
+
+ {errors.addresses?.[index]?.country && (
+
+ {errors.addresses[index].country?.message}
+
+ )}
+
+
+
+
+ ))}
+
+
+ {/* Array-level error (e.g. "Add at least one address") */}
+ {errors.addresses?.root && (
+ {errors.addresses.root.message}
+ )}
+
+
+ append(EMPTY_ADDRESS)}
+ className="text-sm text-brand-400 hover:text-brand-300"
+ >
+ + Add address
+
+
+
+ {isPending ? 'Saving…' : 'Save Addresses'}
+
+
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
useFieldArray requires control
+
+ useFieldArray is from react-hook-form (not this library). It
+ needs the control object from useActionForm. You cannot use
+ it with the standalone register spread pattern.
+
+
+
+
field.id as key (not index)
+
+ useFieldArray generates stable IDs for each row. Always use{' '}
+ field.id as the React key, not the array index. Index-based
+ keys cause incorrect animations and state mismatches when rows are removed.
+
+
+
+
+ {'register(`name.${index}.field`)'} — dot notation
+
+
+ Use template literal dot-notation to register nested array fields. RHF will collect
+ them into a properly structured array in getValues() and on submit.
+
+
+
+
Use withZod (JSON action), not FormData
+
+ FormData doesn't natively support nested arrays. Always use withZod{' '}
+ (which sends JSON) for forms with useFieldArray. If you must use a
+ FormData action, serialize the array manually with JSON.stringify.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Using the array index as key
+
+
+ When you remove item at index 1 from [0, 1, 2], React reuses the DOM node for the
+ new index 1 (previously index 2). Input values get mixed up. Always use{' '}
+ field.id.
+
+
+
+
+ ⚠
+
+
Per-item errors path typo
+
+ The errors path is errors.addresses?.[index]?.street?.message — note
+ the optional chaining at each level. Missing any level causes a runtime error when
+ there are no errors.
+
+
+
+
+ ⚠
+
+
+ EMPTY_ADDRESS defined inside the component
+
+
+ Define the empty item template outside the component (or with{' '}
+ useCallback / useMemo). An inline object creates a new
+ reference on every render and can cause unexpected append behaviour.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/file-upload/page.tsx b/apps/docs/app/recipes/file-upload/page.tsx
new file mode 100644
index 0000000..7fb12e5
--- /dev/null
+++ b/apps/docs/app/recipes/file-upload/page.tsx
@@ -0,0 +1,322 @@
+export default function FileUploadPage() {
+ return (
+
+
+
+
+
+
+ Tier 3 · Specialized
+
+ Recipe #9
+
+
File Upload
+
+ File uploads break the default JSON action model. This recipe shows how to use a FormData
+ action, validate file type and size, show a preview, and surface a loading indicator.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ File uploads are fundamentally different from text-field submissions. Files must travel
+ over the wire as multipart/form-data, not JSON. That means you need a{' '}
+ FormDataServerAction instead of the withZod JSON wrapper.
+ Validation also shifts: instead of a Zod schema on an object, you inspect the{' '}
+ File object's size, type, and{' '}
+ name on the server.
+
+
+ Client-side validation (type/size checks before upload) is implemented with a custom{' '}
+ schema using Zod's .refine(). This recipe covers the full
+ pattern: action, form, preview, and a graceful loading state.
+
+
+
+ {/* Full Example */}
+
+ Full Example — Avatar Upload
+
+
+
+ actions.ts — FormData action (arity 2)
+
+
+
{`'use server'
+
+const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
+const MAX_SIZE_BYTES = 5 * 1024 * 1024 // 5 MB
+
+export async function uploadAvatarAction(
+ prevState: unknown,
+ formData: FormData
+) {
+ const file = formData.get('avatar') as File | null
+
+ if (!file || file.size === 0) {
+ return { errors: { avatar: ['Please select a file'] } }
+ }
+
+ if (file.size > MAX_SIZE_BYTES) {
+ return { errors: { avatar: ['File must be under 5 MB'] } }
+ }
+
+ if (!ALLOWED_TYPES.includes(file.type)) {
+ return { errors: { avatar: ['Only JPEG, PNG, and WebP images are allowed'] } }
+ }
+
+ // Upload to your storage provider (S3, Cloudflare R2, Vercel Blob, etc.)
+ const url = await storage.upload(file)
+
+ // Update the user's profile with the new avatar URL
+ await db.profiles.update({
+ where: { userId: session.userId },
+ data: { avatarUrl: url },
+ })
+
+ return { success: true, url }
+}`}
+
+
+
+
+
+ avatar-upload-form.tsx
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import { useRef, useState } from 'react'
+import { z } from 'zod'
+import { uploadAvatarAction } from './actions'
+
+// Client-side schema for early validation (before upload)
+const avatarSchema = z.object({
+ avatar: z
+ .custom()
+ .refine((files) => files?.length > 0, 'Please select a file')
+ .refine(
+ (files) => files?.[0]?.size <= 5 * 1024 * 1024,
+ 'File must be under 5 MB'
+ )
+ .refine(
+ (files) => ['image/jpeg', 'image/png', 'image/webp'].includes(files?.[0]?.type),
+ 'Only JPEG, PNG, and WebP images are allowed'
+ ),
+})
+
+type AvatarResult = {
+ success?: boolean
+ url?: string
+ errors?: { avatar?: string[] }
+}
+
+export function AvatarUploadForm() {
+ const [preview, setPreview] = useState(null)
+ const previewUrlRef = useRef(null)
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isPending, isSubmitSuccessful, actionResult },
+ } = useActionForm<{ avatar: FileList }, AvatarResult>(uploadAvatarAction, {
+ defaultValues: { avatar: undefined as unknown as FileList },
+ schema: avatarSchema,
+ validationMode: 'onChange',
+ onSuccess: (result) => {
+ // Revoke the local preview URL to free memory
+ if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
+ },
+ })
+
+ const handleFileChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ // Generate a local preview before uploading
+ if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
+ const url = URL.createObjectURL(file)
+ previewUrlRef.current = url
+ setPreview(url)
+ }
+
+ return (
+
+ {/* Preview */}
+ {preview && !isSubmitSuccessful && (
+
+
+
+ )}
+
+ {/* Confirmed avatar */}
+ {isSubmitSuccessful && actionResult?.url && (
+
+
+
+
+
Avatar updated!
+
+ )}
+
+
+
+
+ Choose an image
+
+
+ {errors.avatar && (
+
{errors.avatar.message}
+ )}
+
+
+
+ {isPending ? (
+
+
+
+
+
+ Uploading…
+
+ ) : (
+ 'Upload Avatar'
+ )}
+
+
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
FormDataServerAction (arity 2)
+
+ File uploads require the (prevState, formData) => Promise signature.
+ The hook detects this automatically (by checking the function's{' '}
+ length) and sends the data as multipart/form-data instead of
+ JSON.
+
+
+
+
z.custom<FileList>().refine()
+
+ Zod does not have a built-in file type. Use z.custom<FileList>(){' '}
+ with .refine() for client-side validation. Avoid{' '}
+ z.instanceof(File) — it fails in SSR environments where File{' '}
+ is not defined.
+
+
+
+
URL.createObjectURL + cleanup
+
+ Use URL.createObjectURL for instant image previews. Always call{' '}
+ URL.revokeObjectURL when the preview is no longer needed to prevent
+ memory leaks. Store the URL in a useRef to access it across renders.
+
+
+
+
Next.js Server Action file size limit
+
+ Next.js Server Actions have a default body size limit of 1 MB. For larger files,
+ configure experimental.serverActionsBodySizeLimit in{' '}
+ next.config.mjs, or upload directly to your storage provider from the
+ client (signed URL pattern).
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Using withZod for file uploads
+
+
+ withZod serialises data as JSON. Files cannot be serialised to JSON —
+ you will receive an empty object. Always use the raw FormData action signature for
+ file uploads.
+
+
+
+
+ ⚠
+
+
+ Using setValue for file inputs
+
+
+ Browser security prevents programmatically setting file input values. Use{' '}
+ register normally and listen to onChange for the preview
+ logic. Do not try to control the file input's value via setValue.
+
+
+
+
+ ⚠
+
+
Forgetting to revoke object URLs
+
+ Every URL.createObjectURL call holds a reference to the file in memory.
+ Revoke it in onSuccess or in a useEffect cleanup to avoid
+ memory leaks in long-running sessions.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/login-form/page.tsx b/apps/docs/app/recipes/login-form/page.tsx
new file mode 100644
index 0000000..8c1ea1a
--- /dev/null
+++ b/apps/docs/app/recipes/login-form/page.tsx
@@ -0,0 +1,257 @@
+export default function LoginFormRecipePage() {
+ return (
+
+ {/* Back */}
+
+
+ {/* Header */}
+
+
+
+ Tier 1 · Must-have
+
+ Recipe #1
+
+
Login Form
+
+ The foundation pattern. Learn how withZod, useActionForm, and
+ error display all fit together before moving to more complex recipes.
+
+
+ See live demo →
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ The login form is the first form every developer builds with this library. It establishes
+ the core mental model: a typed server action wrapped with withZod, a client
+ component calling useActionForm, and a clean pattern for displaying both
+ client-side validation errors and server-side errors on the same fields.
+
+
+ Getting this right means understanding the difference between isSubmitting{' '}
+ and isPending, knowing that withZod auto-attaches the schema for
+ client-side inference, and handling the two typical success flows — redirect or in-place
+ success state.
+
+
+
+ {/* Full Example */}
+
+ Full Example
+
+
+
+ actions.ts
+
+
+
{`'use server'
+import { z } from 'zod'
+import { withZod } from 'hookform-action-core/with-zod'
+
+const loginSchema = z.object({
+ email: z.string().email('Please enter a valid email'),
+ password: z.string().min(8, 'Password must be at least 8 characters'),
+})
+
+export const loginAction = withZod(loginSchema, async (data) => {
+ // data is fully typed: { email: string; password: string }
+
+ // Simulate a credentials check
+ if (data.email === 'wrong@example.com') {
+ return { errors: { email: ['Invalid credentials'] } }
+ }
+
+ // On success: return { success: true } or call redirect()
+ return { success: true }
+})`}
+
+
+
+
+
+ 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: '' },
+ })
+
+ if (isSubmitSuccessful) {
+ return Login successful! Redirecting...
+ }
+
+ return (
+
+
+
Email
+
+ {errors.email &&
{errors.email.message}
}
+
+
+
+
Password
+
+ {errors.password &&
{errors.password.message}
}
+
+
+
+ {isPending ? 'Signing in…' : 'Sign In'}
+
+
+ )
+}`}
+
+
+
+
+ Next.js redirect variant:
+ call redirect('/dashboard') from next/navigation{' '}
+ inside withZod. The hook treats the thrown redirect as a successful
+ submission — isSubmitSuccessful will be true, but the page will
+ navigate away before you can render a success state.
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
withZod(schema, handler)
+
+ Wraps the server action and attaches the Zod schema as action.__schema.{' '}
+ useActionForm reads it automatically for client-side validation. On
+ validation failure, returns {'{ errors: { field: string[] } }'} without
+ ever calling the handler.
+
+
+
+
isPending vs isSubmitting
+
+ isPending reflects React's useTransition — it stays{' '}
+ true for the full async round-trip to the server. Use it to disable the
+ submit button. isSubmitting is the synchronous RHF snapshot and may not
+ cover the full window in Next.js transitions.
+
+
+
+
handleSubmit()
+
+ Called with no arguments to get the submit handler:{' '}
+ {''} . Optionally pass an{' '}
+ onValid callback that runs with typed data before the action is called:{' '}
+ {'handleSubmit((data) => console.log(data))'}.
+
+
+
+
defaultValues
+
+ Always provide defaultValues. Without them, RHF initialises fields as{' '}
+ undefined, which causes React's uncontrolled-to-controlled warning
+ when the user starts typing, and breaks dirty-field tracking.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Using isSubmitting to disable the button
+
+
+ Use isPending instead. In Next.js the action runs inside a transition,
+ and isSubmitting may revert to false before the server
+ response arrives.
+
+
+
+
+ ⚠
+
+
+ Calling redirect() and also returning success: true
+
+
+ redirect() throws internally — the return statement is unreachable.
+ Choose one: either redirect or return a result. Doing both is dead code.
+
+
+
+
+ ⚠
+
+
+ Returning errors outside the {'{ errors: { field: string[] } }'} shape
+
+
+ The default errorMapper only recognises that exact shape. If your
+ action returns a different format, use a custom errorMapper — see{' '}
+
+ Recipe #11
+
+ .
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/modal-form/page.tsx b/apps/docs/app/recipes/modal-form/page.tsx
new file mode 100644
index 0000000..d1c2bf4
--- /dev/null
+++ b/apps/docs/app/recipes/modal-form/page.tsx
@@ -0,0 +1,380 @@
+export default function ModalFormPage() {
+ return (
+
+
+
+
+
+
+ Tier 2 · Common
+
+ Recipe #7
+
+
Modal / Dialog Form
+
+ Forms inside modals have a different lifecycle — they must reset when opened, close on
+ success, and handle focus correctly. This recipe covers the canonical pattern and
+ Shadcn/ui Dialog integration.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ A form inside a modal behaves differently from a page-level form. If the modal does not
+ unmount when closed, the form keeps its state — including errors from the last submission.
+ When the user opens it again, they see stale data. When they close it after a success,
+ they may briefly see an empty form before the animation completes.
+
+
+ This recipe solves these issues with two patterns: the{' '}
+ useEffect reset pattern (for modals that stay
+ mounted) and the key remount pattern (the
+ nuclear option that guarantees a fresh form). It also shows Shadcn/ui Dialog{' '}
+ integration.
+
+
+
+ {/* Pattern 1 */}
+
+ Pattern 1 — useEffect reset (stays mounted)
+
+ Best for modals that are conditionally rendered but not unmounted on close (e.g. animated
+ with CSS opacity). Reset the form when the modal opens and close it on success.
+
+
+
+
+ create-item-modal.tsx
+
+
+
{`'use client'
+import { useEffect } from 'react'
+import { useActionForm } from 'hookform-action'
+import { createItemAction } from './actions'
+
+interface CreateItemModalProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+export function CreateItemModal({ isOpen, onClose }: CreateItemModalProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isPending },
+ } = useActionForm(createItemAction, {
+ defaultValues: { title: '', description: '' },
+ onSuccess: () => {
+ // Step 1: close the modal
+ onClose()
+ // Step 2: reset AFTER close to avoid flash of empty form
+ // We use setTimeout(0) to let the close animation start first
+ setTimeout(() => reset(), 150)
+ },
+ })
+
+ // When the modal opens, clear any leftover state from the previous session
+ useEffect(() => {
+ if (isOpen) reset()
+ }, [isOpen, reset])
+
+ if (!isOpen) return null
+
+ return (
+
+
+
+ Create Item
+
+
+
+
+
+ Title
+
+
+ {errors.title && (
+
{errors.title.message}
+ )}
+
+
+
+
+ Description
+
+
+ {errors.description && (
+
{errors.description.message}
+ )}
+
+
+
+
+ Cancel
+
+
+ {isPending ? 'Creating…' : 'Create'}
+
+
+
+
+
+ )
+}`}
+
+
+
+
+ {/* Pattern 2: key remount */}
+
+ Pattern 2 — key remount (unmount on close)
+
+ The simplest approach: unmount and remount the form component on every open by passing a{' '}
+ key that changes. React destroys and recreates the component, giving you a
+ guaranteed fresh state. Best for modals where no CSS animation is needed.
+
+
+
+
+ parent-component.tsx
+
+
+
{`'use client'
+import { useState } from 'react'
+import { CreateItemModal } from './create-item-modal'
+
+export function ItemList() {
+ const [isOpen, setIsOpen] = useState(false)
+ const [openCount, setOpenCount] = useState(0)
+
+ const handleOpen = () => {
+ setOpenCount((c) => c + 1) // increment key to force remount
+ setIsOpen(true)
+ }
+
+ return (
+ <>
+ New Item
+
+ {isOpen && (
+ // key changes on every open → CreateItemModal is remounted with fresh state
+ setIsOpen(false)}
+ />
+ )}
+ >
+ )
+}`}
+
+
+
+
+ {/* Shadcn/ui Dialog */}
+
+ Shadcn/ui Dialog integration
+
+ Shadcn's Dialog keeps content mounted by default. Use the{' '}
+ useEffect reset pattern and wire onOpenChange to close the
+ modal.
+
+
+
+
+ create-item-dialog.tsx
+
+
+
{`'use client'
+import { useEffect } from 'react'
+import { useActionForm } from 'hookform-action'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { createItemAction } from './actions'
+
+interface CreateItemDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function CreateItemDialog({ open, onOpenChange }: CreateItemDialogProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isPending },
+ } = useActionForm(createItemAction, {
+ defaultValues: { title: '', description: '' },
+ onSuccess: () => {
+ onOpenChange(false)
+ setTimeout(() => reset(), 150)
+ },
+ })
+
+ useEffect(() => {
+ if (open) reset()
+ }, [open, reset])
+
+ return (
+
+
+
+ Create Item
+
+
+
+
+
+
Title
+
+ {errors.title &&
{errors.title.message}
}
+
+
+
Description
+
+ {errors.description && (
+
{errors.description.message}
+ )}
+
+
+
+
+
+ onOpenChange(false)}>Cancel
+
+ {isPending ? 'Creating…' : 'Create'}
+
+
+
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
reset() in useEffect on open
+
+ When the modal opens, reset the form to clear any state from the previous session.
+ This is essential for modals that stay mounted between opens (e.g. animated with CSS).
+
+
+
+
setTimeout before reset on close
+
+ If you call reset() synchronously in onSuccess before
+ closing the modal, the user briefly sees an empty form during the close animation. A
+ small delay prevents the flash.
+
+
+
+
autoFocus on first field
+
+ Always add autoFocus to the first input in the modal for accessibility.
+ Screen reader users and keyboard users expect focus to move to the dialog content when
+ it opens.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Hiding the modal with CSS instead of unmounting
+
+
+ If you use display: none or visibility: hidden to hide the
+ modal, the form component stays mounted and retains all its state. The user will see
+ stale errors and values on the next open. Always pair this approach with an explicit{' '}
+ useEffect reset.
+
+
+
+
+ ⚠
+
+
+ Using isSubmitSuccessful to close the modal
+
+
+ isSubmitSuccessful does not give you access to the action result.
+ Prefer onSuccess, which receives the full typed result and is a cleaner
+ place to trigger onClose().
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/multi-step-wizard/page.tsx b/apps/docs/app/recipes/multi-step-wizard/page.tsx
new file mode 100644
index 0000000..796f7fa
--- /dev/null
+++ b/apps/docs/app/recipes/multi-step-wizard/page.tsx
@@ -0,0 +1,373 @@
+export default function MultiStepWizardPage() {
+ return (
+
+
+
+
+
+
+ Tier 1 · Must-have
+
+ ★ Featured
+ Recipe #5
+
+
Multi-Step Wizard
+
+ Per-step validation, sessionStorage-backed progress, and safe cleanup on final submit —
+ the most complete demonstration of what makes this library different.
+
+
+ See live demo →
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ Multi-step wizards are notoriously painful to implement: you need to validate only the
+ current step's fields before advancing, persist progress so the user can close the
+ tab and come back, and cleanly submit all steps' data in a single action at the end.
+
+
+ hookform-action solves this with a single hook instance spanning all steps.
+ trigger(fields) validates a subset of fields without submitting.{' '}
+ persistKey automatically serialises form state to sessionStorage{' '}
+ on every change. And clearPersistedData() removes the draft cleanly on
+ success. No external state manager needed.
+
+
+
+ {/* Full Example */}
+
+ Full Example
+
+
+
+ actions.ts
+
+
+
{`'use server'
+import { z } from 'zod'
+import { withZod } from 'hookform-action-core/with-zod'
+
+// Single schema covering all wizard steps
+const onboardingSchema = z.object({
+ // Step 1: Personal Info
+ firstName: z.string().min(2, 'First name must be at least 2 characters'),
+ lastName: z.string().min(2, 'Last name must be at least 2 characters'),
+ email: z.string().email('Enter a valid email address'),
+ // Step 2: Company
+ company: z.string().min(1, 'Company name is required'),
+ role: z.string().min(1, 'Role is required'),
+ // Step 3: Plan
+ plan: z.enum(['free', 'pro', 'enterprise'], {
+ errorMap: () => ({ message: 'Please select a plan' }),
+ }),
+})
+
+export const onboardingAction = withZod(onboardingSchema, async (data) => {
+ // All fields are available and typed here
+ await createAccount(data)
+ return { success: true }
+})`}
+
+
+
+
+
+ wizard-form.tsx
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import { useState } from 'react'
+import { onboardingAction } from './actions'
+
+// Map each step index to the fields it owns
+const STEP_FIELDS = {
+ 0: ['firstName', 'lastName', 'email'],
+ 1: ['company', 'role'],
+ 2: ['plan'],
+} as const
+
+export function OnboardingWizard() {
+ const [step, setStep] = useState(0)
+
+ const {
+ register,
+ handleSubmit,
+ trigger,
+ clearPersistedData,
+ formState: { errors, isPending, isSubmitSuccessful },
+ } = useActionForm(onboardingAction, {
+ defaultValues: {
+ firstName: '', lastName: '', email: '',
+ company: '', role: '',
+ plan: '',
+ },
+ // Automatically save progress to sessionStorage
+ persistKey: 'onboarding-wizard',
+ persistDebounce: 250,
+ onSuccess: () => {
+ // Remove the draft once the wizard is complete
+ clearPersistedData()
+ },
+ })
+
+ // Validate only the current step's fields before advancing
+ const handleNext = async () => {
+ const fields = STEP_FIELDS[step as 0 | 1 | 2]
+ const valid = await trigger(fields as never[])
+ if (valid) setStep((s) => s + 1)
+ }
+
+ if (isSubmitSuccessful) {
+ return (
+
+
🎉 Welcome aboard!
+
Your account is ready.
+
+ )
+ }
+
+ return (
+
+ {/* Progress indicator */}
+
+ {['Personal', 'Company', 'Plan'].map((label, i) => (
+
+
+ {i + 1}
+
+
+ {label}
+
+
+ ))}
+
+
+ {/* Step 1 */}
+ {step === 0 && (
+
+
+
First Name
+
+ {errors.firstName &&
{errors.firstName.message}
}
+
+
+
Last Name
+
+ {errors.lastName &&
{errors.lastName.message}
}
+
+
+
Email
+
+ {errors.email &&
{errors.email.message}
}
+
+
+ )}
+
+ {/* Step 2 */}
+ {step === 1 && (
+
+
+
Company
+
+ {errors.company &&
{errors.company.message}
}
+
+
+
Role
+
+ {errors.role &&
{errors.role.message}
}
+
+
+ )}
+
+ {/* Step 3 */}
+ {step === 2 && (
+
+
Choose a plan
+ {(['free', 'pro', 'enterprise'] as const).map((p) => (
+
+
+ {p.charAt(0).toUpperCase() + p.slice(1)}
+
+ ))}
+ {errors.plan &&
{errors.plan.message}
}
+
+ )}
+
+ {/* Navigation */}
+
+ {step > 0 && (
+ setStep((s) => s - 1)}>
+ Back
+
+ )}
+ {step < 2 ? (
+
+ Next
+
+ ) : (
+
+ {isPending ? 'Creating account…' : 'Complete Setup'}
+
+ )}
+
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
trigger(fields)
+
+ Runs client-side validation on a specific subset of fields and returns{' '}
+ true if all pass. Pass only the fields of the current step to avoid
+ surfacing errors from future steps prematurely. Calling trigger() with no
+ arguments validates everything — avoid this between steps.
+
+
+
+
persistKey + persistDebounce
+
+ persistKey enables automatic sessionStorage persistence. The
+ form state is saved under this key on every change (debounced by{' '}
+ persistDebounce ms, default 300) and restored on mount. The user can
+ close the browser and return to find their progress intact.
+
+
+
+
clearPersistedData()
+
+ Manually removes the persisted state for this form's persistKey.
+ Always call it in onSuccess after wizard completion — otherwise the next
+ user to open the form (or the current user if they start again) will see the completed
+ wizard's data pre-filled.
+
+
+
+
Single hook instance across all steps
+
+ All fields are registered in one useActionForm call. The active step is
+ controlled by plain useState. This means the entire form state is
+ consistent at all times, and the final submit sends all fields in one action call.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Calling trigger() without arguments between steps
+
+
+ This validates all fields — including ones in future steps the user has not seen yet
+ — and shows errors prematurely. Always pass the explicit list of fields for the
+ current step.
+
+
+
+
+ ⚠
+
+
+ Using isSubmitSuccessful to advance between steps
+
+
+ isSubmitSuccessful only becomes true after the final
+ action call succeeds. Use it exclusively as the guard for the completion screen.
+ Step navigation is purely local state.
+
+
+
+
+ ⚠
+
+
+ Forgetting clearPersistedData() after submission
+
+
+ The persistence happens automatically on every change. After success, the persisted
+ data is not removed automatically — you must call clearPersistedData(){' '}
+ in onSuccess, or the next visit to the form will restore the completed
+ wizard.
+
+
+
+
+ ⚠
+
+
+ The current step is not persisted by default
+
+
+ persistKey saves field values, not the step index. If the user returns
+ after a refresh, the form starts at step 0 with the field values intact. To also
+ persist the step, store it in sessionStorage alongside the form, or
+ include a hidden currentStep field.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/nested-fields/page.tsx b/apps/docs/app/recipes/nested-fields/page.tsx
new file mode 100644
index 0000000..262312e
--- /dev/null
+++ b/apps/docs/app/recipes/nested-fields/page.tsx
@@ -0,0 +1,358 @@
+export default function NestedFieldsPage() {
+ return (
+
+
+
+
+
+
+ Tier 3 · Specialized
+
+ Recipe #10
+
+
Nested Fields & Sub-components
+
+ Compose large forms from smaller, focused components using useFormContext —
+ without passing register, errors, or control as
+ props through every level.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ Large forms — checkout, onboarding, profile settings — are easier to maintain when split
+ into focused sub-components. But React Hook Form's register,{' '}
+ control, and formState normally need to be prop-drilled down the
+ tree, which creates tight coupling and verbose component signatures.
+
+
+ The {''} component from hookform-action wraps its children
+ in React Hook Form's FormProvider automatically. Any component inside
+ the tree can call useFormContext() to access the full RHF API without any
+ prop passing.
+
+
+
+ {/* Full Example */}
+
+ Full Example — Checkout Form
+
+
+
+ actions.ts
+
+
+
{`'use server'
+import { z } from 'zod'
+import { withZod } from 'hookform-action-core/with-zod'
+
+export const checkoutSchema = z.object({
+ customer: z.object({
+ name: z.string().min(1, 'Name is required'),
+ email: z.string().email('Enter a valid email'),
+ }),
+ shipping: z.object({
+ street: z.string().min(1, 'Street is required'),
+ city: z.string().min(1, 'City is required'),
+ country: z.string().min(2, 'Select a country'),
+ }),
+})
+
+export type CheckoutValues = z.infer
+
+export const checkoutAction = withZod(checkoutSchema, async (data) => {
+ await processOrder(data)
+ return { success: true }
+})`}
+
+
+
+
+
+ checkout-form.tsx — Root component
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import { Form } from 'hookform-action-core' // or from 'hookform-action'
+import { checkoutAction } from './actions'
+import type { CheckoutValues } from './actions'
+import { CustomerSection } from './CustomerSection'
+import { ShippingSection } from './ShippingSection'
+
+export function CheckoutForm() {
+ // useActionForm returns the full RHF API plus action integration
+ const form = useActionForm(checkoutAction, {
+ defaultValues: {
+ customer: { name: '', email: '' },
+ shipping: { street: '', city: '', country: '' },
+ },
+ })
+
+ return (
+ // automatically wraps children in
+ // Sub-components can use useFormContext() without any prop passing
+
+
+
+
+
+ {form.formState.isPending ? 'Processing…' : 'Place Order'}
+
+
+ )
+}`}
+
+
+
+
+
+ CustomerSection.tsx — Sub-component
+
+
+
{`'use client'
+import { useFormContext } from 'react-hook-form'
+import type { CheckoutValues } from './actions'
+
+export function CustomerSection() {
+ // No props needed — reads form context from the parent
+ const {
+ register,
+ formState: { errors },
+ } = useFormContext()
+
+ return (
+
+ Customer Info
+
+
+
+
Full Name
+
+ {errors.customer?.name && (
+
{errors.customer.name.message}
+ )}
+
+
+
+
Email
+
+ {errors.customer?.email && (
+
{errors.customer.email.message}
+ )}
+
+
+
+ )
+}`}
+
+
+
+
+
+ ShippingSection.tsx — Sub-component with Controller
+
+
+
{`'use client'
+import { Controller, useFormContext } from 'react-hook-form'
+import type { CheckoutValues } from './actions'
+
+export function ShippingSection() {
+ const {
+ register,
+ control, // ← needed for Controller / useFieldArray in sub-components
+ formState: { errors },
+ } = useFormContext()
+
+ return (
+
+ Shipping Address
+
+
+
+
Street
+
+ {errors.shipping?.street && (
+
{errors.shipping.street.message}
+ )}
+
+
+
+
+
City
+
+ {errors.shipping?.city && (
+
{errors.shipping.city.message}
+ )}
+
+
+
+
Country
+ {/* Controller example — for custom select components */}
+
(
+
+ Select…
+ United States
+ Brazil
+
+ )}
+ />
+ {errors.shipping?.country && (
+ {errors.shipping.country.message}
+ )}
+
+
+
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
{''} — automatic FormProvider
+
+ The {''} component from hookform-action wraps children
+ in RHF's FormProvider automatically. You do not need to add{' '}
+ FormProvider yourself. All sub-components in the tree can call{' '}
+ useFormContext().
+
+
+
+
useFormContext<TFieldValues>()
+
+ Pass the form's type parameter to get typed access to register,{' '}
+ errors, and control. Without the generic, you get{' '}
+ FieldValues (essentially any
+ ).
+
+
+
+
Dot-notation for nested paths
+
+ Register deeply nested fields with dot notation:{' '}
+ register('customer.name'). RHF will collect these into a
+ properly structured object. The Zod schema's shape must match exactly.
+
+
+
+
control in sub-components
+
+ control is needed for Controller and{' '}
+ useFieldArray inside sub-components. Get it from{' '}
+ useFormContext() — not passed as a prop from the parent.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Double-wrapping with FormProvider
+
+
+ {''} already includes a FormProvider. Adding another
+ one around it creates nested contexts and causes useFormContext() to
+ read from the innermost (empty) provider.
+
+
+
+
+ ⚠
+
+
+ Sub-components using useFormContext outside {''}
+
+
+ If a sub-component is rendered outside the {''} tree (e.g. in a
+ portal, a story, or a test), useFormContext() will return{' '}
+ undefined and throw. Wrap test cases in a mock{' '}
+ FormProvider.
+
+
+
+
+ ⚠
+
+
+ Mismatched dot-notation path and schema shape
+
+
+ If you register customer.name but the Zod schema expects{' '}
+ customerName (flat), the field will not validate correctly and the
+ error path will not match. Keep the schema and the register paths in
+ sync.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/optimistic-ui/page.tsx b/apps/docs/app/recipes/optimistic-ui/page.tsx
new file mode 100644
index 0000000..2057d2c
--- /dev/null
+++ b/apps/docs/app/recipes/optimistic-ui/page.tsx
@@ -0,0 +1,294 @@
+export default function OptimisticUIPage() {
+ return (
+
+
+
+
+
+
+ Tier 2 · Common
+
+ ⚡ Advanced feature
+ Recipe #6
+
+
Optimistic UI Updates
+
+ Show the result of a submission instantly — before the server responds — and roll back
+ automatically if something goes wrong.
+
+
+ See live demo →
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ Optimistic UI is the difference between a form that feels fast and one that feels slow.
+ Instead of waiting 500ms for the server to respond before updating the list, you project
+ the expected result immediately and confirm (or roll back) once the server replies.
+
+
+ hookform-action implements this via React's useOptimistic{' '}
+ (React 19) with a fallback for React 18. You provide an optimisticData{' '}
+ reducer that produces the projected state from the current data and the form values. The
+ hook handles the transition, confirmation, and rollback automatically.
+
+
+
+ {/* Full Example */}
+
+ Full Example — Optimistic Todo List
+
+
+
+ actions.ts
+
+
+
{`'use server'
+
+export interface Todo {
+ id: string
+ text: string
+ done: boolean
+}
+
+// In-memory store for demo purposes
+let todos: Todo[] = [
+ { id: '1', text: 'Read the docs', done: true },
+ { id: '2', text: 'Build something', done: false },
+]
+
+export async function addTodoAction(raw: unknown) {
+ // Simulate network latency
+ await new Promise((r) => setTimeout(r, 1200))
+
+ const data = raw as { text: string }
+
+ if (!data.text?.trim()) {
+ return { errors: { text: ['Todo text is required'] }, todos }
+ }
+
+ // Type 'fail' to simulate a server error and trigger rollback
+ if (data.text.toLowerCase().includes('fail')) {
+ throw new Error('Server error — optimistic update will be rolled back.')
+ }
+
+ const newTodo: Todo = {
+ id: crypto.randomUUID(),
+ text: data.text.trim(),
+ done: false,
+ }
+
+ todos = [...todos, newTodo]
+ return { todos }
+}`}
+
+
+
+
+
+ todo-form.tsx
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import { type Todo, addTodoAction } from './actions'
+
+type AddTodoResult = { todos: Todo[]; errors?: { text?: string[] } }
+
+const initialTodos: Todo[] = [
+ { id: '1', text: 'Read the docs', done: true },
+ { id: '2', text: 'Build something', done: false },
+]
+
+export function TodoForm() {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isPending, actionResult },
+ optimistic,
+ } = useActionForm<{ text: string }, AddTodoResult, Todo[]>(addTodoAction, {
+ defaultValues: { text: '' },
+
+ // ── Optimistic UI configuration ─────────────────────────────
+ optimisticKey: 'todos',
+
+ // Initial "confirmed" data before any submissions
+ optimisticInitial: initialTodos,
+
+ // Reducer: given the current list and the submitted values,
+ // return the projected list to show immediately
+ optimisticData: (current, values) => [
+ ...current,
+ { id: \`temp-\${Date.now()}\`, text: values.text, done: false },
+ ],
+ // ────────────────────────────────────────────────────────────
+
+ onSuccess: () => reset(),
+ })
+
+ // Decide which data to render:
+ // • While the action is in flight → show the optimistic (projected) list
+ // • After the action resolves → show the confirmed list from the server
+ const confirmedTodos = actionResult?.todos ?? initialTodos
+ const todos = optimistic?.isPending
+ ? (optimistic.data ?? confirmedTodos)
+ : confirmedTodos
+
+ return (
+
+
+ {todos.map((todo) => (
+
+ {todo.done ? '✅' : '⬜'} {todo.text}
+ {todo.id.startsWith('temp-') && ' (saving…)'}
+
+ ))}
+
+
+
+
+ {errors.text && {errors.text.message}
}
+ Add
+
+
+ )
+}`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
optimisticData (reducer)
+
+ A pure function (currentData, formValues) => newData that computes the
+ projected state. It runs synchronously on submit — before the server responds. Keep it
+ fast and side-effect-free.
+
+
+
+
optimistic.data vs actionResult
+
+ optimistic.data is the projected state (may contain temporary items with
+ fake IDs). actionResult is the confirmed state returned by the server.
+ Use the pattern shown above: prefer optimistic.data while{' '}
+ optimistic.isPending is true, then switch to actionResult.
+
+
+
+
Automatic rollback on error
+
+ When the action throws, the hook automatically reverts optimistic.data to
+ the last confirmed state. You can also trigger a manual rollback via{' '}
+ optimistic.rollback() for business-logic-driven reversals.
+
+
+
+
React 18 / React 19 compatibility
+
+ On React 19 the hook uses the native useOptimistic API. On React 18 it
+ uses a local state fallback. The behaviour is identical — you don't need to
+ change any code when upgrading React.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Rendering optimistic.data after the action resolves
+
+
+ After the action succeeds, optimistic.data still contains the projected
+ state with temporary IDs. Always switch to actionResult once{' '}
+ optimistic.isPending is false.
+
+
+
+
+ ⚠
+
+
+ Inline object literal for optimisticInitial
+
+
+ Define optimisticInitial outside the component or use{' '}
+ useMemo. An inline array literal creates a new reference on every
+ render and resets the optimistic state unexpectedly.
+
+
+
+
+ ⚠
+
+
+ Forgetting reset() in onSuccess
+
+
+ After a successful add, the text input stays filled. Call reset() in{' '}
+ onSuccess to clear the input so the user can type the next item
+ immediately.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/page.tsx b/apps/docs/app/recipes/page.tsx
new file mode 100644
index 0000000..13e81dd
--- /dev/null
+++ b/apps/docs/app/recipes/page.tsx
@@ -0,0 +1,271 @@
+export default function RecipesPage() {
+ return (
+
+
+
+
+
+ Recipes
+
+
Recipes & Common Patterns
+
+ Complete, copy-paste patterns for the most common hookform-action use cases.
+ Each recipe includes a working server action, a typed client component, key concepts, and
+ pitfalls to avoid.
+
+
+
+ {/* ── Tier 1: Must-have ──────────────────────────────────── */}
+
+
+
Start Here
+
+ Tier 1 · Must-have
+
+
+
+ These five recipes cover the patterns every app needs. Learn them first.
+
+
+
+
+ {/* ── Tier 2: Common ────────────────────────────────────── */}
+
+
+
Intermediate Patterns
+
+ Tier 2 · Common
+
+
+
+ Patterns you will reach for often as your app grows.
+
+
+
+
+ {/* ── Tier 3: Advanced ──────────────────────────────────── */}
+
+
+
Advanced Patterns
+
+ Tier 3 · Specialized
+
+
+
+ Edge cases, ecosystem integrations, and power-user patterns.
+
+
+
+
+
+
+ All recipes use real, working code with full TypeScript types. Each pattern maps directly
+ to a capability of the hookform-action API.{' '}
+
+ Read the API reference →
+
+
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/reset-after-success/page.tsx b/apps/docs/app/recipes/reset-after-success/page.tsx
new file mode 100644
index 0000000..4f73436
--- /dev/null
+++ b/apps/docs/app/recipes/reset-after-success/page.tsx
@@ -0,0 +1,322 @@
+export default function ResetAfterSuccessPage() {
+ return (
+
+
+
+
+
+
+ Tier 1 · Must-have
+
+ Recipe #3
+
+
Reset After Success
+
+ Three canonical patterns for post-submission UX: redirect, in-place reset with a toast,
+ and showing a success state. Choosing the wrong one leads to subtle bugs.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ "The form doesn't clear after I submit" is one of the most common issues
+ raised by developers new to the library. The reason is that{' '}
+ isSubmitSuccessful stays true indefinitely, and{' '}
+ reset() needs to be called explicitly. These are intentional RHF behaviours —
+ but they need to be wired correctly.
+
+
+ There are three distinct patterns depending on your UX goal. Pick the right one and you
+ avoid ghost states, stale data, and infinite success screens.
+
+
+
+ {/* Pattern 1 */}
+
+ Pattern 1 — Redirect after submit
+
+ Best for create-and-navigate flows: create a post, place an order, complete onboarding.
+ The server calls redirect() and the page navigates away — no client-side
+ reset needed.
+
+
+
+
+ actions.ts
+
+
+
{`'use server'
+import { redirect } from 'next/navigation'
+import { withZod } from 'hookform-action-core/with-zod'
+import { postSchema } from './schema'
+
+export const createPostAction = withZod(postSchema, async (data) => {
+ const post = await db.posts.create({ data })
+ // redirect() throws internally — the return below is unreachable
+ redirect(\`/posts/\${post.id}\`)
+})`}
+
+
+
+
+ Note:
+ After redirect() the hook sets isSubmitSuccessful = true, but
+ the page navigates away immediately so this state is never rendered. You do not need to
+ handle it on the client.
+
+
+
+ {/* Pattern 2 */}
+
+ Pattern 2 — In-place reset with onSuccess
+
+ Best for forms that stay on the same page after submission: comment boxes, subscription
+ forms, quick-add widgets. Use onSuccess to call reset() and
+ optionally show a toast.
+
+
+
+
+ comment-form.tsx
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import { addCommentAction } from './actions'
+
+export function CommentForm() {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isPending },
+ } = useActionForm(addCommentAction, {
+ defaultValues: { text: '' },
+ onSuccess: () => {
+ reset() // Clears all fields back to defaultValues
+ // toast.success('Comment posted!') // optional side effect
+ },
+ })
+
+ return (
+
+
+ {errors.text && {errors.text.message}
}
+
+ {isPending ? 'Posting…' : 'Post Comment'}
+
+
+ )
+}`}
+
+
+
+
+
+ Resetting to different values (e.g. after edit)
+
+
+
{`onSuccess: (result) => {
+ // Pass new values to reset() — useful after an edit form
+ // where the server returns the updated record
+ reset({
+ title: result.data.title,
+ body: result.data.body,
+ })
+}`}
+
+
+
+
+ {/* Pattern 3 */}
+
+ Pattern 3 — isSubmitSuccessful guard
+
+ Best for single-use forms where you want to replace the form with a success message:
+ contact forms, RSVP forms, one-time redemptions.
+
+
+
+
+ contact-form.tsx
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import { sendMessageAction } from './actions'
+
+export function ContactForm() {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isPending, isSubmitSuccessful },
+ } = useActionForm(sendMessageAction, {
+ defaultValues: { name: '', email: '', message: '' },
+ })
+
+ // Replace the form entirely once submitted
+ if (isSubmitSuccessful) {
+ return (
+
+
Message sent!
+
We'll get back to you within 24 hours.
+
+ )
+ }
+
+ return (
+
+
+ {errors.name && {errors.name.message}
}
+
+ {errors.email && {errors.email.message}
}
+
+ {errors.message && {errors.message.message}
}
+
+ {isPending ? 'Sending…' : 'Send Message'}
+
+
+ )
+}`}
+
+
+
+
+ {/* Bonus: Wizard cleanup */}
+
+ Bonus — Clearing persistence after wizard submit
+
+ When using persistKey, always call clearPersistedData() in{' '}
+ onSuccess to remove the saved draft from sessionStorage.
+ Otherwise the user will see stale data if they open the form again.
+
+
+
+
{`const { handleSubmit, clearPersistedData } = useActionForm(wizardAction, {
+ defaultValues: { ... },
+ persistKey: 'onboarding-wizard',
+ onSuccess: () => {
+ clearPersistedData() // ← remove draft from sessionStorage
+ router.push('/dashboard')
+ },
+})`}
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
reset()
+
+ Resets all fields to defaultValues, clears all errors, and resets RHF
+ state (isDirty, isSubmitSuccessful, etc.).{' '}
+ reset(newValues) additionally updates the stored default values — useful
+ after editing a record.
+
+
+
+
isSubmitSuccessful
+
+ Becomes true after a successful action call (no field errors returned)
+ and stays true until reset() is called or the component
+ unmounts. This is intentional — use it as the guard for your success UI.
+
+
+
+
onSuccess callback
+
+ Receives the full action result and fires only when the action succeeds (no field
+ errors). The best place to call reset(), show a toast, or trigger a
+ router navigation.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Calling reset() in a useEffect watching{' '}
+ isSubmitSuccessful
+
+
+ This creates a double-render cycle. Prefer onSuccess — it fires
+ synchronously after the action resolves, before any re-render.
+
+
+
+
+ ⚠
+
+
+ Resetting inside a modal — flash of empty form
+
+
+ If you reset() and then close the modal in onSuccess, the
+ form briefly shows empty before the modal animation completes. Prefer resetting in
+ the onClose handler instead, or use a key prop to remount.
+
+
+
+
+ ⚠
+
+
+ reset() without arguments on an edit form
+
+
+ If defaultValues came from the server and the user just saved new data,
+ calling reset() reverts to the old server data. Use{' '}
+ reset(result.data) to update the baseline after a successful edit.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/signup-server-errors/page.tsx b/apps/docs/app/recipes/signup-server-errors/page.tsx
new file mode 100644
index 0000000..bc33e86
--- /dev/null
+++ b/apps/docs/app/recipes/signup-server-errors/page.tsx
@@ -0,0 +1,300 @@
+export default function SignupServerErrorsPage() {
+ return (
+
+
+
+
+
+
+ Tier 1 · Must-have
+
+ Recipe #2
+
+
Sign Up with Server Validation Errors
+
+ How to return business-logic errors from a server action — duplicate email, taken
+ username, and more — and have them appear on the correct fields automatically.
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ The login form teaches the happy path. This recipe teaches what actually happens in
+ production: the user submits valid data, but the server rejects it because the email is
+ already registered or the username is taken. These are business-logic errors that
+ Zod's schema can't know about.
+
+
+ hookform-action has a built-in contract for this: return{' '}
+ {'{ errors: { field: string[] } }'} from the action and the hook maps it
+ directly to the corresponding React Hook Form fields — no extra wiring needed. This recipe
+ also shows setSubmitError for errors set outside the action, and the{' '}
+ onError callback for side effects like toasts.
+
+
+
+ {/* Full Example */}
+
+ Full Example
+
+
+
+ actions.ts
+
+
+
{`'use server'
+import { z } from 'zod'
+import { withZod } from 'hookform-action-core/with-zod'
+
+const signupSchema = z.object({
+ email: z.string().email('Invalid email address'),
+ username: z.string().min(3, 'Username must be at least 3 characters'),
+ password: z.string().min(8, 'Password must be at least 8 characters'),
+})
+
+export const signupAction = withZod(signupSchema, async (data) => {
+ // data is typed: { email: string; username: string; password: string }
+
+ // Simulate DB uniqueness checks
+ if (data.email === 'taken@example.com') {
+ return { errors: { email: ['This email is already registered'] } }
+ }
+
+ if (data.username === 'admin') {
+ return { errors: { username: ['This username is not available'] } }
+ }
+
+ // Multiple field errors at once
+ if (data.email.endsWith('@disposable.com')) {
+ return {
+ errors: {
+ email: ['Disposable email addresses are not allowed'],
+ username: ['Please use your real name'],
+ },
+ }
+ }
+
+ return { success: true }
+})`}
+
+
+
+
+
+ signup-form.tsx
+
+
+
{`'use client'
+import { useActionForm } from 'hookform-action'
+import type { InferActionResult } from 'hookform-action'
+import { signupAction } from './actions'
+
+// Infer the exact return type of the action for type-safe access
+type SignupResult = InferActionResult
+
+export function SignupForm() {
+ const {
+ register,
+ handleSubmit,
+ setSubmitError,
+ formState: { errors, isPending, isSubmitSuccessful, actionResult },
+ } = useActionForm(signupAction, {
+ defaultValues: { email: '', username: '', password: '' },
+ onSuccess: (result: SignupResult) => {
+ // result.success === true here
+ console.log('Account created!')
+ },
+ onError: (result: SignupResult | Error) => {
+ // Fires when field errors are returned OR the action throws
+ // Good place for analytics, toast notifications, etc.
+ if (result instanceof Error) {
+ console.error('Unexpected error:', result.message)
+ }
+ },
+ })
+
+ if (isSubmitSuccessful) {
+ return (
+
+
Account created! Check your email to verify.
+
+ )
+ }
+
+ return (
+
+
+
Email
+
+ {/* Shows both Zod client errors AND server errors from the action */}
+ {errors.email &&
{errors.email.message}
}
+
+
+
+
Username
+
+ {errors.username &&
{errors.username.message}
}
+
+
+
+
Password
+
+ {errors.password &&
{errors.password.message}
}
+
+
+ {/* Optional: surface a global error if the action returns one */}
+ {actionResult && 'message' in actionResult && actionResult.message && (
+ {actionResult.message}
+ )}
+
+
+ {isPending ? 'Creating account…' : 'Sign Up'}
+
+
+ )
+}`}
+
+
+
+
+
+ Using setSubmitError manually
+
+
+
{`// Sometimes you need to set a server error outside the action response,
+// for example after a network check or in an event handler:
+
+const { setSubmitError } = useActionForm(signupAction, { ... })
+
+// Set an error on a specific field programmatically:
+setSubmitError('email', 'This email was flagged as invalid by our provider')`}
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
{'{ errors: { field: string[] } }'}
+
+ The default error contract. Return this shape from any action and{' '}
+ defaultErrorMapper automatically sets the corresponding RHF field errors.
+ The key is the field name, the value is an array of message strings (only the first
+ message is shown by RHF by default).
+
+
+
+
formState.actionResult
+
+ The raw return value of the last action call, preserved with full type safety via{' '}
+ InferActionResult<typeof action>. Use it to surface global messages
+ or access non-error data from the response.
+
+
+
+
setSubmitError(field, message)
+
+ Programmatically sets a field error as if it came from the server. Useful for
+ client-side checks that happen outside the normal submit flow, such as async
+ email-availability lookups on blur.
+
+
+
+
onError callback
+
+ Fires when the action returns field errors or throws an exception. Use it for
+ side effects — toast notifications, error logging, analytics — without polluting the
+ action or the component's render logic.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
+ Server errors don't clear when the user retypes
+
+
+ This is intentional RHF behaviour. A server error set via the error mapper persists
+ until the next submit (or until you call clearErrors(field) manually).
+ To clear on input, use validationMode: 'onChange' with a
+ client-side schema.
+
+
+
+
+ ⚠
+
+
+ Distinguishing field errors from global errors
+
+
+ The errors object only contains field-level errors from RHF. To display
+ a global error message (e.g. "Service unavailable"), read it from{' '}
+ actionResult and render it separately outside the field markup.
+
+
+
+
+ ⚠
+
+
+ withZod already returns field errors — don't double-validate
+
+
+ If the schema fails, withZod short-circuits and returns{' '}
+ {'{ errors }'} before your handler runs. Do not call{' '}
+ schema.safeParse again inside the handler.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/recipes/standalone-fetch/page.tsx b/apps/docs/app/recipes/standalone-fetch/page.tsx
new file mode 100644
index 0000000..901394f
--- /dev/null
+++ b/apps/docs/app/recipes/standalone-fetch/page.tsx
@@ -0,0 +1,359 @@
+export default function StandaloneFetchPage() {
+ return (
+
+
+
+
+
+
+ Tier 3 · Specialized
+
+ 🚀 Standalone
+ Recipe #12
+
+
Standalone — Vite, Remix & Custom APIs
+
+ Use hookform-action-standalone with any async function — fetch,{' '}
+ axios, tRPC, or a custom client — in apps that don't use Next.js Server
+ Actions.
+
+
+ Standalone Guide →
+
+
+
+ {/* Why it matters */}
+
+ Why it matters
+
+ Not every React app uses Next.js or Server Actions. Vite SPAs, Remix apps, Astro islands,
+ and React Native projects all need the same ergonomics — typed validation, automatic error
+ mapping, optimistic UI — but with a plain async function as the submit handler.
+
+
+ The standalone adapter exposes exactly the same hook API. The only difference is: instead
+ of passing a Server Action as the first argument, you pass an options object with a{' '}
+ submit function. Everything else — schema,{' '}
+ optimisticData, persistKey, onSuccess,{' '}
+ errorMapper — works identically.
+
+
+
+ {/* Full Example */}
+
+ Full Example — Login Form (Vite SPA)
+
+
+
+ api.ts — Typed fetch wrapper
+
+
+
{`// A reusable fetch wrapper that returns a typed result
+// (replace with axios, ky, or your preferred client)
+
+export interface LoginResult {
+ success?: boolean
+ token?: string
+ errors?: {
+ email?: string[]
+ password?: string[]
+ }
+ message?: string
+}
+
+export async function loginApi(data: {
+ email: string
+ password: string
+}): Promise {
+ const res = await fetch('/api/auth/login', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ // Add auth headers here if needed: Authorization: \`Bearer \${token}\`
+ },
+ body: JSON.stringify(data),
+ })
+
+ // Treat non-2xx as a structured error result, not a thrown error
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}))
+ return {
+ errors: body.errors ?? { email: ['Login failed. Please try again.'] },
+ message: body.message,
+ }
+ }
+
+ return res.json()
+}`}
+
+
+
+
+
+ login-form.tsx — Standalone hook usage
+
+
+
{`import { useActionForm } from 'hookform-action-standalone'
+import { z } from 'zod'
+import { loginApi, type LoginResult } from './api'
+
+// Client-side validation schema (same as in Next.js)
+const loginSchema = z.object({
+ email: z.string().email('Enter a valid email'),
+ password: z.string().min(8, 'Password must be at least 8 characters'),
+})
+
+export function LoginForm() {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isPending, isSubmitSuccessful },
+ } = useActionForm<{ email: string; password: string }, LoginResult>({
+ // Key difference from Next.js: pass an options object with 'submit'
+ submit: loginApi,
+
+ defaultValues: { email: '', password: '' },
+ schema: loginSchema,
+ validationMode: 'onChange',
+
+ onSuccess: (result) => {
+ if (result.token) {
+ localStorage.setItem('token', result.token)
+ window.location.href = '/dashboard'
+ }
+ },
+ onError: (result) => {
+ if (result instanceof Error) {
+ // toast.error('Network error. Please check your connection.')
+ }
+ },
+ })
+
+ if (isSubmitSuccessful) {
+ return Signed in! Redirecting…
+ }
+
+ return (
+
+
+
Email
+
+ {errors.email &&
{errors.email.message}
}
+
+
+
+
Password
+
+ {errors.password &&
{errors.password.message}
}
+
+
+
+ {isPending ? 'Signing in…' : 'Sign In'}
+
+
+ )
+}`}
+
+
+
+
+
+ Bonus — axios variant
+
+
+
{`import axios from 'axios'
+
+// The submit function can throw — the hook catches it and fires onError
+export async function loginApi(data: { email: string; password: string }) {
+ try {
+ const { data: result } = await axios.post('/api/auth/login', data)
+ return result
+ } catch (err) {
+ if (axios.isAxiosError(err) && err.response) {
+ // Return structured errors instead of throwing
+ return err.response.data
+ }
+ throw err // Re-throw for unexpected errors (network timeout, etc.)
+ }
+}`}
+
+
+
+
+ {/* API diff table */}
+
+ Next.js vs Standalone — API Diff
+
+
+
+
+ Feature
+
+ hookform-action (Next.js)
+
+
+ hookform-action-standalone
+
+
+
+
+
+ Import
+ hookform-action
+ hookform-action-standalone
+
+
+ Hook signature
+ useActionForm(action, options?)
+
+ useActionForm({ submit, ...options })
+
+
+
+ formAction
+ ✅ Available
+ ❌ Not available
+
+
+ schema / withZod
+ ✅ Auto-detected from action
+ ✅ Pass schema option
+
+
+ persistKey
+ ✅
+ ✅
+
+
+ optimisticData
+ ✅
+ ✅
+
+
+ errorMapper
+ ✅
+ ✅
+
+
+
+
+
+
+ {/* Key Concepts */}
+
+ Key Concepts
+
+
+
submit: async (data) => TResult
+
+ Any async function that receives the validated form data and returns a result. It can
+ call fetch, axios, a tRPC procedure, a GraphQL mutation, or
+ any other async operation. If it throws, the hook catches it and fires{' '}
+ onError.
+
+
+
+
Structured errors vs thrown errors
+
+ For HTTP 4xx errors, return a structured result (with errors) instead of
+ throwing. This allows the errorMapper to set field errors correctly. Only
+ throw for unexpected errors (5xx, network failures) — those trigger{' '}
+ onError with an Error instance.
+
+
+
+
withZod is not used server-side in standalone
+
+ withZod is a wrapper for Next.js Server Actions. In standalone mode, pass
+ the schema via the schema option for client-side validation only.
+ Server-side validation must be implemented inside your submit function or
+ API handler.
+
+
+
+
+
+ {/* Pitfalls */}
+
+ ⚠️ Pitfalls
+
+
+ ⚠
+
+
Throwing on HTTP 4xx errors
+
+ If your fetch wrapper throws on 4xx, the hook receives an Error — not a
+ structured result. The errorMapper won't fire for thrown errors.
+ Return a structured object instead so field errors can be set automatically.
+
+
+
+
+ ⚠
+
+
+ No formAction in standalone
+
+
+ The standalone adapter does not expose formAction — there is no support
+ for the {''} pattern without JavaScript. Progressive
+ enhancement requires the Next.js adapter.
+
+
+
+
+ ⚠
+
+
+ Inline submit function
+
+
+ Defining submit as an inline arrow function inside the component
+ creates a new reference on every render. Extract it to module scope or wrap with{' '}
+ useCallback to avoid re-initialising the hook.
+
+
+
+
+
+
+ {/* Related */}
+
+
+ )
+}
diff --git a/apps/docs/app/standalone/page.tsx b/apps/docs/app/standalone/page.tsx
index 45a6353..7cd3a72 100644
--- a/apps/docs/app/standalone/page.tsx
+++ b/apps/docs/app/standalone/page.tsx
@@ -8,7 +8,7 @@ export default function StandalonePage() {
- v3
+ v4
·
hookform-action-standalone
diff --git a/apps/docs/app/submit-lifecycle/page.tsx b/apps/docs/app/submit-lifecycle/page.tsx
new file mode 100644
index 0000000..6c9244a
--- /dev/null
+++ b/apps/docs/app/submit-lifecycle/page.tsx
@@ -0,0 +1,232 @@
+export default function SubmitLifecyclePage() {
+ return (
+
+
+
+
Submit Lifecycle
+
+ A practical mental model for isSubmitting, isPending,{' '}
+ isSubmitSuccessful, submitErrors, and actionResult.
+
+
+ {/* 1. Simple explanation */}
+
+ 1) Simple Explanation
+
+ Think of hookform-action as RHF + an action lifecycle layer.
+
+
+
+ Idle : no request in flight.
+
+
+ Submit starts :{' '}
+ isSubmitting = true, isPending = true,{' '}
+ submitErrors = null.
+
+
+ Success :{' '}
+ isPending = false, isSubmitting = false,{' '}
+ isSubmitSuccessful = true, submitErrors = null,{' '}
+ actionResult = result.
+
+
+ Field error result :{' '}
+ isPending = false, isSubmitting = false,{' '}
+ isSubmitSuccessful = false, submitErrors = ...,{' '}
+ actionResult = result.
+
+
+ Thrown error (network/exception) :{' '}
+ isPending = false, isSubmitting = false,{' '}
+ isSubmitSuccessful = false. Handle this path in onError.
+
+
+
+
+ {/* 2. State table */}
+
+ 2) State Table
+
+
+
+
+ State
+ What it means
+ When it changes
+ Use it for
+
+
+
+
+ formState.isSubmitting
+
+ Submit is running (RHF + action internal state).
+
+
+ True at submit start, false on finish/failure.
+
+ Legacy compatibility and debugging.
+
+
+ formState.isPending
+ Transition + request pending window.
+
+ Derived from useTransition plus internal pending state.
+
+
+ Disable button and show loading.
+
+
+
+ formState.isSubmitSuccessful
+
+ Last completed submit ended without field errors.
+
+
+ True on success, false on validation errors or thrown errors.
+
+ Post-submit success logic.
+
+
+ formState.submitErrors
+ Structured field-level error record.
+
+ Set on client/server validation errors, cleared at new submit start.
+
+ Render field/server validation feedback.
+
+
+ formState.actionResult
+
+ Full result object from last completed action response.
+
+
+ Updated on success and field-error responses.
+
+ Read confirmed payload with success guards.
+
+
+
+
+
+
+ {/* 3. Correct usage examples */}
+
+ 3) Correct Usage
+ Disable + loading with isPending
+
+
{`const {
+ handleSubmit,
+ formState: { isPending },
+} = useActionForm(action, { defaultValues })
+
+return (
+
+
+ {isPending ? 'Saving...' : 'Save'}
+
+
+)`}
+
+
+ Post-submit success logic
+
+
{`const {
+ formState: { isPending, isSubmitSuccessful },
+} = useActionForm(action, { defaultValues })
+
+useEffect(() => {
+ if (!isPending && isSubmitSuccessful) {
+ toast.success('Saved')
+ // router.push('/dashboard')
+ }
+}, [isPending, isSubmitSuccessful])`}
+
+
+ Validation errors vs confirmed result
+
+
{`const {
+ formState: { submitErrors, actionResult, isSubmitSuccessful, isPending },
+} = useActionForm(action, { defaultValues })
+
+const hasFieldErrors = !!submitErrors
+const confirmedData =
+ !isPending && isSubmitSuccessful ? actionResult : null`}
+
+
+
+ {/* 4. Misinterpretations */}
+
+ 4) Common Misinterpretations
+
+
+ Using isSubmitting for button lock/loading. Prefer isPending.
+
+
+ Treating actionResult as automatic success. It can also hold an
+ error-shaped result.
+
+
+ Running success side-effects only with isSubmitSuccessful. Gate with{' '}
+ !isPending too.
+
+
+ Expecting submitErrors for thrown exceptions. Thrown errors belong to{' '}
+ onError.
+
+
+
+
+ {/* 5. Documentation recommendations */}
+
+ 5) Recommended Docs Snippets
+
+
+
Submit Button Snippet
+
+ Always show disabled and loading from isPending.
+
+
+
+
Success Effect Snippet
+
+ Use !isPending && isSubmitSuccessful to trigger post-submit effects.
+
+
+
+
Validation Errors Snippet
+
+ Show submitErrors for field-level API errors and keep RHF{' '}
+ errors in sync.
+
+
+
+
Result Guard Snippet
+
+ Read actionResult only behind explicit success guards.
+
+
+
+
+
+
+
+ How this maps to RHF + transitions + Server Actions
+
+
+ RHF still owns base form mechanics (register, errors,
+ touched/dirty state). hookform-action wraps submit execution, runs inside{' '}
+ startTransition, then composes the final form state (`isPending =
+ transitionPending || internalPending`). In Next.js mode, the adapter also bridges{' '}
+ FormData/prevState signatures before passing control to the same
+ core lifecycle.
+
+
+
+ )
+}
diff --git a/apps/docs/app/troubleshooting/page.tsx b/apps/docs/app/troubleshooting/page.tsx
new file mode 100644
index 0000000..ff9af40
--- /dev/null
+++ b/apps/docs/app/troubleshooting/page.tsx
@@ -0,0 +1,125 @@
+type TroubleshootingItem = {
+ symptom: string
+ likelyCause: string
+ quickFix: string
+}
+
+const troubleshootingItems: TroubleshootingItem[] = [
+ {
+ symptom: 'I click submit and nothing happens.',
+ likelyCause:
+ 'handleSubmit is not wired correctly, submit button type is wrong, or client validation blocked execution.',
+ quickFix: 'Use onSubmit={handleSubmit()} and inspect formState.errors after click.',
+ },
+ {
+ symptom: 'Action is not called even with valid-looking fields.',
+ likelyCause: 'Client-side schema validation failed before the network request.',
+ quickFix: 'Check validationMode and verify each field against the schema.',
+ },
+ {
+ symptom: 'Server errors are not shown on inputs.',
+ likelyCause: 'Returned error shape does not match default mapper expectations.',
+ quickFix: 'Return { errors: Record } or implement errorMapper.',
+ },
+ {
+ symptom: 'Errors show on the wrong field (nested/array forms).',
+ likelyCause: 'Error keys do not match RHF field paths.',
+ quickFix: 'Use RHF paths like address.city and items.0.price in server error keys.',
+ },
+ {
+ symptom: 'Submit button enables too early and allows double submits.',
+ likelyCause: 'UI is using isSubmitting instead of isPending.',
+ quickFix: 'Disable by formState.isPending for end-to-end submit state.',
+ },
+ {
+ symptom: 'Old values keep returning in edit forms.',
+ likelyCause: 'persistKey restored an old draft and overrode defaults.',
+ quickFix: 'Scope persistKey per entity and clear persisted drafts on load/success.',
+ },
+ {
+ symptom: 'optimistic is undefined or has no effect.',
+ likelyCause: 'Incomplete optimistic setup or UI is rendering confirmed data only.',
+ quickFix: 'Provide optimisticKey + optimisticData and render optimistic.data while pending.',
+ },
+ {
+ symptom: 'File upload fails or file is empty on server.',
+ likelyCause: 'Using JSON flow for file input instead of FormData action handling.',
+ quickFix: 'Use a FormData-based action and validate file type/size server-side.',
+ },
+ {
+ symptom: 'onError does not fire when the form is invalid.',
+ likelyCause: 'Invalid client form never reaches action execution.',
+ quickFix:
+ 'Use formState.errors for client invalid state; onError handles action failure paths.',
+ },
+ {
+ symptom: 'Standalone flow expects formAction but it does not exist.',
+ likelyCause: 'Standalone API signature differs from Next.js adapter.',
+ quickFix: 'Use submit in options and trigger with handleSubmit().',
+ },
+]
+
+const checklist = [
+ 'Confirm package choice: next adapter vs standalone adapter.',
+ 'Confirm schema and validationMode match your intended UX.',
+ 'Confirm action return shape for field errors.',
+ 'Confirm field names and error keys use identical RHF paths.',
+ 'Confirm submit buttons are gated by isPending.',
+ 'Confirm persistKey scope and clear strategy.',
+ 'Confirm optimistic settings and rendering path.',
+ 'Confirm file flows use FormData, not JSON.',
+]
+
+export default function TroubleshootingPage() {
+ return (
+
+
+
+
+
+
+ {troubleshootingItems.map((item) => (
+
+
{item.symptom}
+
+ Likely cause: {item.likelyCause}
+
+
+ Quick fix: {item.quickFix}
+
+
+ ))}
+
+
+
+ 60-second checklist
+
+ {checklist.map((item, index) => (
+
+ {index + 1}.
+ {item}
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/docs/app/why/page.tsx b/apps/docs/app/why/page.tsx
new file mode 100644
index 0000000..5c82c53
--- /dev/null
+++ b/apps/docs/app/why/page.tsx
@@ -0,0 +1,433 @@
+export default function WhyPage() {
+ return (
+
+
+
+
Manual setup vs hookform-action
+
+ A concrete comparison of wiring React Hook Form with Server Actions by hand, versus using{' '}
+ hookform-action as the integration layer.
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* 1. Comparison table */}
+ {/* ------------------------------------------------------------------ */}
+
+ Feature comparison
+
+ Each row maps to real code you either write yourself or get for free.
+
+
+
+
+
+ Concern
+ Manual
+ hookform-action
+
+
+
+
+ Server-side Zod validation
+
+ Write schema.safeParse() + format errors in every action
+
+
+ withZod(schema, handler) — one wrapper, done
+
+
+
+ Error mapping to RHF fields
+
+ Call setError() for each field after parsing the action result
+
+
+ Automatic via defaultErrorMapper — override with{' '}
+ errorMapper when needed
+
+
+
+ Client-side validation
+
+ Pass resolver to useForm and keep schema in sync with
+ server
+
+
+ Set validationMode — schema auto-detected from withZod
+
+
+
+ Pending / loading state
+
+ useTransition + useState wiring around{' '}
+ startTransition
+
+
+ formState.isPending backed by useTransition internally
+
+
+
+ Optimistic UI
+
+ useOptimistic + useTransition + manual rollback logic
+
+
+ optimisticData option — hook handles pending state and rollback
+
+
+
+ Multi-step persistence
+
+ Custom useEffect + sessionStorage reads/writes per step
+
+
+ persistKey option — debounced, SSR-safe, clears on success
+
+
+
+ FormData support
+
+ Detect action arity and convert FormData to object manually
+
+
+ Detected automatically via action arity — withZod handles conversion
+
+
+
+ Success / error callbacks
+
+ Inline .then()/.catch() chains after calling the action
+
+
+ onSuccess / onError options
+
+
+
+ Debug tooling
+ RHF DevTools (state only)
+
+ hookform-action-devtools — full submission history + form state panel
+
+
+
+
+
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* 2. Workflow comparison */}
+ {/* ------------------------------------------------------------------ */}
+
+ Workflow comparison
+
+ {/* Manual */}
+
+
Manual — steps per form
+
+ Define Zod schema in a shared module
+
+ Pass zodResolver(schema) to useForm
+
+
+ In the Server Action: call schema.safeParse(data), format{' '}
+ fieldErrors, return them
+
+
+ In the component: inspect action result, loop over errors, call{' '}
+ setError(field, …) for each
+
+
+ Wire useTransition around the action call to get isPending
+
+
+ If you need optimistic UI: add useOptimistic, compute next state, pass
+ to startTransition, handle rollback
+
+
+ If you need persistence: write a useEffect to save/restore from{' '}
+ sessionStorage, debounce it, clear on unmount
+
+
+
+ Each new form repeats steps 3–7 from scratch.
+
+
+
+ {/* hookform-action */}
+
+
+ hookform-action — steps per form
+
+
+
+ Wrap server action with withZod(schema, handler)
+
+
+ Call useActionForm(action, options) in the component
+
+
+ Use register, handleSubmit(), and{' '}
+ formState.errors as you normally would with RHF
+
+
+
+ Steps 4–7 from the manual list become opt-in options: validationMode,{' '}
+ optimisticData, persistKey.
+
+
+
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* 3. Side-by-side code */}
+ {/* ------------------------------------------------------------------ */}
+
+ Code comparison — Login form
+
+ Same feature set: server validation, automatic error display, pending state.
+
+
+ {/* Server Action */}
+
+ 01 / Server Action
+
+
+
+
+ Manual
+
+
+
{`// actions.ts
+'use server'
+import { z } from 'zod'
+
+const schema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8),
+})
+
+export async function loginAction(data: unknown) {
+ const parsed = schema.safeParse(data)
+
+ if (!parsed.success) {
+ // Must manually format Zod errors
+ // every time, in every action
+ return {
+ errors: parsed.error.flatten().fieldErrors,
+ }
+ }
+
+ const user = await authenticate(parsed.data)
+ if (!user) {
+ return { errors: { email: ['Invalid credentials'] } }
+ }
+
+ return { success: true }
+}`}
+
+
+
+
+ hookform-action
+
+
+
{`// actions.ts
+'use server'
+import { z } from 'zod'
+import { withZod } from 'hookform-action-core/with-zod'
+
+const schema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8),
+})
+
+export const loginAction = withZod(
+ schema,
+ // handler only runs if validation passes
+ // data is fully typed — no casts needed
+ async (data) => {
+ const user = await authenticate(data)
+ if (!user) {
+ return { errors: { email: ['Invalid credentials'] } }
+ }
+ return { success: true }
+ }
+)`}
+
+
+
+
+ {/* Client component */}
+
+ 02 / Client component
+
+
+
+
+ Manual
+
+
+
{`// LoginForm.tsx
+'use client'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useTransition } from 'react'
+import { z } from 'zod'
+import { loginAction } from './actions'
+
+// Schema must be re-imported/re-defined
+// on the client to feed zodResolver
+const schema = z.object({
+ email: z.string().email(),
+ password: z.string().min(8),
+})
+
+type Fields = z.infer
+
+export function LoginForm() {
+ const [isPending, startTransition] = useTransition()
+
+ const {
+ register,
+ handleSubmit,
+ setError,
+ formState: { errors, isSubmitting },
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: { email: '', password: '' },
+ })
+
+ const onSubmit = (values: Fields) => {
+ startTransition(async () => {
+ const result = await loginAction(values)
+ // Must manually walk error keys
+ if (result?.errors) {
+ for (const [field, messages] of
+ Object.entries(result.errors)) {
+ setError(field as keyof Fields, {
+ message: (messages as string[])[0],
+ })
+ }
+ }
+ })
+ }
+
+ return (
+
+
+ {errors.email && {errors.email.message}
}
+
+ {errors.password && {errors.password.message}
}
+
+ {isPending ? 'Signing in…' : 'Sign In'}
+
+
+ )
+}`}
+
+
+
+
+ hookform-action
+
+
+
+
+
+ {/* Delta callout */}
+
+
+
~55
+
lines — manual client component
+
+
+
~25
+
lines — with hookform-action
+
+
+
−3
+
+ concepts to keep in sync (resolver, transition, setError loop)
+
+
+
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* 4. Conclusion */}
+ {/* ------------------------------------------------------------------ */}
+
+ Bottom line
+
+
+ The manual approach works. React Hook Form, Zod, and Server Actions are well-designed
+ primitives and wiring them yourself is absolutely viable on a small form.
+
+
+ The friction compounds as the app grows: every new form repeats the same{' '}
+ safeParse boilerplate on the server, the same setError loop on
+ the client, the same useTransition wrapper for pending state. When you add
+ optimistic updates or multi-step persistence the surface area grows further.
+
+
+ hookform-action doesn't hide any of those primitives — it codifies the
+ patterns you would write anyway into a single hook and a single server wrapper. You keep
+ full access to the underlying useForm instance and can still pass any RHF
+ option. The goal is to remove the repetitive plumbing, not to abstract away control.
+
+
+
+
+
+ )
+}
diff --git a/packages/core/README.md b/packages/core/README.md
index 9048668..93d82f9 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -1,11 +1,21 @@
# hookform-action-core
-Framework-agnostic core for **hookform-action** — provides the foundational hooks, Zod integration, persistence helpers, and type system shared by all adapters.
+The framework-agnostic core of **hookform-action** for typed React Hook Form submit flows: `withZod`, automatic Zod error mapping, persistence, and optimistic UI. Shared by all adapters.
[](https://www.npmjs.com/package/hookform-action-core)
[](https://www.npmjs.com/package/hookform-action-core)
[](https://github.com/gabpaesschulz/hookform-action/blob/main/LICENSE)
+## Mental Model
+
+`hookform-action-core` is the **framework-agnostic engine**. It doesn't know about Next.js routing or `fetch` — it only understands: _"I have an async function and a schema; wire them into React Hook Form."_
+
+- **`withZod`** — wraps your handler with Zod validation on the server and attaches the schema so the client hook can detect it automatically for real-time validation
+- **`useActionFormCore`** — low-level hook managing the full submit lifecycle (client validation → async call → error mapping → optimistic state → persistence)
+- **Adapters** (`hookform-action`, `hookform-action-standalone`) are thin wrappers that call `useActionFormCore` with environment-specific submission logic
+
+Most users never install this package directly — they install an adapter instead.
+
> **Most users should install an adapter instead:**
>
> - [`hookform-action`](https://www.npmjs.com/package/hookform-action) — for **Next.js** Server Actions
diff --git a/packages/core/package.json b/packages/core/package.json
index 5b98f96..4695595 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,7 +1,7 @@
{
"name": "hookform-action-core",
"version": "4.0.3",
- "description": "Seamless integration between React Hook Form and Server Actions with Zod validation, automatic type inference, optimistic UI, and multi-step form persistence.",
+ "description": "Framework-agnostic core for typed React Hook Form submit flows: withZod, error mapping, optimistic UI, and persistence.",
"keywords": [
"next.js",
"react-hook-form",
diff --git a/packages/core/src/__tests__/client-validation.test.ts b/packages/core/src/__tests__/client-validation.test.ts
index 11902ed..725423b 100644
--- a/packages/core/src/__tests__/client-validation.test.ts
+++ b/packages/core/src/__tests__/client-validation.test.ts
@@ -31,7 +31,7 @@ describe('useActionForm – client-side Zod validation (explicit schema)', () =>
useActionForm(action, {
defaultValues: { email: 'invalid', password: 'short' },
schema: signupSchema,
- validationMode: 'onSubmit',
+ clientValidation: 'onSubmit',
}),
)
@@ -44,7 +44,7 @@ describe('useActionForm – client-side Zod validation (explicit schema)', () =>
// Action should NOT have been called because client validation failed
expect(action).not.toHaveBeenCalled()
// Errors should be set
- expect(result.current.formState.submitErrors).not.toBeNull()
+ expect(result.current.formState.serverErrors).not.toBeNull()
})
})
@@ -55,7 +55,7 @@ describe('useActionForm – client-side Zod validation (explicit schema)', () =>
useActionForm(action, {
defaultValues: { email: 'valid@test.com', password: '12345678' },
schema: signupSchema,
- validationMode: 'onSubmit',
+ clientValidation: 'onSubmit',
}),
)
@@ -77,7 +77,7 @@ describe('useActionForm – client-side Zod validation (explicit schema)', () =>
useActionForm(action, {
defaultValues: { email: 'bad', password: '12345678' },
schema: signupSchema,
- validationMode: 'onSubmit',
+ clientValidation: 'onSubmit',
}),
)
@@ -93,14 +93,14 @@ describe('useActionForm – client-side Zod validation (explicit schema)', () =>
})
})
- it('validates onChange when validationMode is onChange', async () => {
+ it('validates onChange when clientValidation is onChange', async () => {
const action = createSuccessAction()
const { result } = renderHook(() =>
useActionForm(action, {
defaultValues: { email: '', password: '' },
schema: signupSchema,
- validationMode: 'onChange',
+ clientValidation: 'onChange',
}),
)
@@ -123,7 +123,7 @@ describe('useActionForm – client-side Zod validation (explicit schema)', () =>
useActionForm(action, {
defaultValues: { email: '', password: '' },
schema: signupSchema,
- validationMode: 'onChange',
+ clientValidation: 'onChange',
}),
)
@@ -170,7 +170,7 @@ describe('useActionForm – auto-detected schema from withZod', () => {
useActionForm(action, {
defaultValues: { email: 'invalid', password: 'short' },
// No explicit schema – should auto-detect from action.__schema
- validationMode: 'onSubmit',
+ clientValidation: 'onSubmit',
}),
)
@@ -182,7 +182,7 @@ describe('useActionForm – auto-detected schema from withZod', () => {
await waitFor(() => {
// Handler should NOT have been called since client validation fails
expect(handler).not.toHaveBeenCalled()
- expect(result.current.formState.submitErrors).not.toBeNull()
+ expect(result.current.formState.serverErrors).not.toBeNull()
})
})
@@ -195,7 +195,7 @@ describe('useActionForm – auto-detected schema from withZod', () => {
useActionForm(action, {
defaultValues: { email: '', password: '' },
// Auto-detects schema, validates on change
- validationMode: 'onChange',
+ clientValidation: 'onChange',
}),
)
@@ -223,7 +223,7 @@ describe('useActionForm – auto-detected schema from withZod', () => {
useActionForm(action, {
defaultValues: { email: 'bad', password: '12345678' },
schema: customSchema, // This should take priority
- validationMode: 'onSubmit',
+ clientValidation: 'onSubmit',
}),
)
diff --git a/packages/core/src/__tests__/optimistic.test.ts b/packages/core/src/__tests__/optimistic.test.ts
index 76388d2..68530e9 100644
--- a/packages/core/src/__tests__/optimistic.test.ts
+++ b/packages/core/src/__tests__/optimistic.test.ts
@@ -53,15 +53,14 @@ describe('useActionForm – optimistic updates', () => {
completed: false,
}
- it('returns optimistic state when optimisticKey and optimisticData are provided', () => {
+ it('returns optimistic state when optimisticReducer is provided', () => {
const action = createUpdateTodoAction()
const { result } = renderHook(() =>
useActionForm(action, {
defaultValues: { title: 'Buy groceries', completed: false },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
completed: formValues.completed,
@@ -75,7 +74,7 @@ describe('useActionForm – optimistic updates', () => {
expect(typeof result.current.optimistic?.rollback).toBe('function')
})
- it('returns undefined optimistic when no optimisticKey is set', () => {
+ it('returns undefined optimistic when no optimisticReducer is set', () => {
const action = createUpdateTodoAction()
const { result } = renderHook(() =>
@@ -93,9 +92,8 @@ describe('useActionForm – optimistic updates', () => {
const { result } = renderHook(() =>
useActionForm(action, {
defaultValues: { title: 'Updated title', completed: true },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
completed: formValues.completed,
@@ -123,9 +121,8 @@ describe('useActionForm – optimistic updates', () => {
const { result } = renderHook(() =>
useActionForm(action, {
defaultValues: { title: '' },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
}),
@@ -151,9 +148,8 @@ describe('useActionForm – optimistic updates', () => {
const { result } = renderHook(() =>
useActionForm(action, {
defaultValues: { title: 'Will fail' },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
}),
@@ -179,9 +175,8 @@ describe('useActionForm – optimistic updates', () => {
const { result } = renderHook(() =>
useActionForm(action, {
defaultValues: { title: 'Updated title', completed: false },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
}),
diff --git a/packages/core/src/__tests__/persistence.test.ts b/packages/core/src/__tests__/persistence.test.ts
index 86236a9..502d214 100644
--- a/packages/core/src/__tests__/persistence.test.ts
+++ b/packages/core/src/__tests__/persistence.test.ts
@@ -132,7 +132,7 @@ describe('useActionForm – persistence', () => {
expect(stored?.email).toBe('manual@test.com')
})
- it('clears persisted data via clearPersistedData()', () => {
+ it('clears persisted data via clearPersisted()', () => {
savePersistedValues(PERSIST_KEY, { email: 'clear@test.com' })
const action: ServerAction<{ success: true }> = vi.fn(async () => ({
@@ -147,7 +147,7 @@ describe('useActionForm – persistence', () => {
)
act(() => {
- result.current.clearPersistedData()
+ result.current.clearPersisted()
})
expect(loadPersistedValues(PERSIST_KEY)).toBeNull()
diff --git a/packages/core/src/__tests__/use-action-form-core.test.ts b/packages/core/src/__tests__/use-action-form-core.test.ts
index 6689f9a..41486c9 100644
--- a/packages/core/src/__tests__/use-action-form-core.test.ts
+++ b/packages/core/src/__tests__/use-action-form-core.test.ts
@@ -50,9 +50,9 @@ describe('useActionFormCore', () => {
expect(result.current.register).toBeDefined()
expect(result.current.handleSubmit).toBeDefined()
expect(result.current.formState).toBeDefined()
- expect(result.current.setSubmitError).toBeDefined()
+ expect(result.current.setServerError).toBeDefined()
expect(result.current.persist).toBeDefined()
- expect(result.current.clearPersistedData).toBeDefined()
+ expect(result.current.clearPersisted).toBeDefined()
expect(result.current.control).toBeDefined()
})
@@ -107,7 +107,7 @@ describe('useActionFormCore', () => {
await waitFor(() => {
expect(result.current.formState.isSubmitSuccessful).toBe(true)
- expect(result.current.formState.actionResult).toEqual({ success: true, data: 'ok' })
+ expect(result.current.formState.lastResult).toEqual({ success: true, data: 'ok' })
})
})
@@ -126,7 +126,7 @@ describe('useActionFormCore', () => {
})
await waitFor(() => {
- expect(result.current.formState.submitErrors).toEqual({
+ expect(result.current.formState.serverErrors).toEqual({
email: ['Invalid email address'],
})
expect(result.current.formState.isSubmitSuccessful).toBe(false)
@@ -224,13 +224,13 @@ describe('useActionFormCore', () => {
)
await act(async () => {
- result.current.clearPersistedData()
+ result.current.clearPersisted()
})
expect(sessionStorage.getItem('test-core-clear')).toBeNull()
})
- // ---- setSubmitError -----------------------------------------------------
+ // ---- setServerError -----------------------------------------------------
it('allows manually setting a server error', async () => {
const submit = createSuccessSubmit()
@@ -241,7 +241,7 @@ describe('useActionFormCore', () => {
)
act(() => {
- result.current.setSubmitError('email', 'Custom server error')
+ result.current.setServerError('email', 'Custom server error')
})
await waitFor(() => {
diff --git a/packages/core/src/__tests__/use-action-form.test.ts b/packages/core/src/__tests__/use-action-form.test.ts
index 0f2a3a1..35d73ff 100644
--- a/packages/core/src/__tests__/use-action-form.test.ts
+++ b/packages/core/src/__tests__/use-action-form.test.ts
@@ -60,9 +60,9 @@ describe('useActionForm', () => {
expect(result.current.register).toBeDefined()
expect(result.current.handleSubmit).toBeDefined()
expect(result.current.formState).toBeDefined()
- expect(result.current.setSubmitError).toBeDefined()
+ expect(result.current.setServerError).toBeDefined()
expect(result.current.persist).toBeDefined()
- expect(result.current.clearPersistedData).toBeDefined()
+ expect(result.current.clearPersisted).toBeDefined()
expect(result.current.formAction).toBeDefined()
})
@@ -97,8 +97,8 @@ describe('useActionForm', () => {
await waitFor(() => {
expect(result.current.formState.isSubmitSuccessful).toBe(true)
- expect(result.current.formState.submitErrors).toBeNull()
- expect(result.current.formState.actionResult).toEqual({
+ expect(result.current.formState.serverErrors).toBeNull()
+ expect(result.current.formState.lastResult).toEqual({
success: true,
data: 'ok',
})
@@ -126,7 +126,7 @@ describe('useActionForm', () => {
await waitFor(() => {
expect(result.current.formState.isSubmitSuccessful).toBe(false)
- expect(result.current.formState.submitErrors).toEqual({
+ expect(result.current.formState.serverErrors).toEqual({
email: ['Invalid email address'],
name: ['Name is required'],
})
@@ -139,14 +139,14 @@ describe('useActionForm', () => {
})
})
- // ---- setSubmitError -----------------------------------------------------
+ // ---- setServerError -----------------------------------------------------
it('allows manually setting a server error', async () => {
const action = createSuccessAction()
const { result } = renderHook(() => useActionForm(action, { defaultValues: { email: '' } }))
act(() => {
- result.current.setSubmitError('email', 'Already taken')
+ result.current.setServerError('email', 'Already taken')
})
await waitFor(() => {
@@ -277,7 +277,7 @@ describe('useActionForm', () => {
await waitFor(() => {
expect(result.current.formState.isSubmitSuccessful).toBe(true)
- expect(result.current.formState.actionResult).toEqual({
+ expect(result.current.formState.lastResult).toEqual({
success: true,
data: 'json-ok',
})
@@ -305,7 +305,7 @@ describe('useActionForm', () => {
await waitFor(() => {
expect(result.current.formState.isSubmitSuccessful).toBe(false)
- expect(result.current.formState.submitErrors).toEqual({
+ expect(result.current.formState.serverErrors).toEqual({
email: ['Invalid email from JSON action'],
})
const emailState = result.current.getFieldState('email')
diff --git a/packages/core/src/core-types.ts b/packages/core/src/core-types.ts
index cec9208..e423c22 100644
--- a/packages/core/src/core-types.ts
+++ b/packages/core/src/core-types.ts
@@ -192,27 +192,41 @@ export interface UseActionFormCoreOptions<
*/
validationMode?: ClientValidationMode
+ /**
+ * Alias for `validationMode`.
+ * @deprecated Use `validationMode` instead.
+ */
+ clientValidation?: ClientValidationMode
+
// ---- Optimistic UI ------------------------------------------------------
/**
- * Unique key identifying the optimistic state.
- * When provided (along with `optimisticData`), enables optimistic updates.
+ * Optional key used to enable optimistic UI mode and identify optimistic data.
*/
optimisticKey?: string
/**
- * Reducer that computes the optimistic state from the current data and
- * the form values being submitted.
- * Required when `optimisticKey` is set.
+ * Reducer that computes optimistic data from current data + submitted values.
*/
optimisticData?: OptimisticReducer
/**
- * Initial data for the optimistic state.
- * This is the "confirmed" state before any optimistic updates.
+ * Initial (confirmed) data used by optimistic mode.
*/
optimisticInitial?: TOptimistic
+ /**
+ * Alias for `optimisticData`.
+ * @deprecated Use `optimisticData` instead.
+ */
+ optimisticReducer?: OptimisticReducer
+
+ /**
+ * Alias for `optimisticInitial`.
+ * @deprecated Use `optimisticInitial` instead.
+ */
+ optimisticDefault?: TOptimistic
+
// ---- Internal plugins (v3) ----------------------------------------------
/**
@@ -233,8 +247,18 @@ export interface ActionFormState {
isSubmitSuccessful: boolean
/** Raw error record returned by the action (via errorMapper). */
submitErrors: FieldErrorRecord | null
- /** The full result from the last action invocation, if any. */
+ /** Full result from the last action invocation, if any. */
actionResult: TResult | null
+ /**
+ * Alias for `submitErrors`.
+ * @deprecated Use `submitErrors` instead.
+ */
+ serverErrors: FieldErrorRecord | null
+ /**
+ * Alias for `actionResult`.
+ * @deprecated Use `actionResult` instead.
+ */
+ lastResult: TResult | null
/**
* `true` while a transition is pending.
*/
@@ -269,6 +293,12 @@ export interface UseActionFormCoreReturn<
*/
setSubmitError: (field: keyof TFieldValues & string, message: string) => void
+ /**
+ * Alias for `setSubmitError`.
+ * @deprecated Use `setSubmitError` instead.
+ */
+ setServerError: (field: keyof TFieldValues & string, message: string) => void
+
/**
* Manually persist the current form state to sessionStorage.
* Only works when `persistKey` is set.
@@ -281,6 +311,12 @@ export interface UseActionFormCoreReturn<
*/
clearPersistedData: () => void
+ /**
+ * Alias for `clearPersistedData`.
+ * @deprecated Use `clearPersistedData` instead.
+ */
+ clearPersisted: () => void
+
/**
* Optimistic state. Only populated when `optimisticKey` is provided.
* Contains `data`, `isPending`, and `rollback()`.
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index b502173..b0aaa0d 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -231,27 +231,40 @@ export interface UseActionFormOptions<
*/
validationMode?: ClientValidationMode
+ /**
+ * Alias for `validationMode`.
+ * @deprecated Use `validationMode` instead.
+ */
+ clientValidation?: ClientValidationMode
+
// ---- v2: Optimistic UI --------------------------------------------------
/**
- * Unique key identifying the optimistic state.
- * When provided (along with `optimisticData`), enables React 19's
- * `useOptimistic` integration.
+ * Optional key used to enable optimistic UI mode and identify optimistic data.
*/
optimisticKey?: string
/**
- * Reducer that computes the optimistic state from the current data and
- * the form values being submitted.
- * Required when `optimisticKey` is set.
+ * Reducer that computes optimistic data from current data + submitted values.
*/
optimisticData?: OptimisticReducer
/**
- * Initial data for the optimistic state.
- * This is the "confirmed" state before any optimistic updates.
+ * Initial (confirmed) data used by optimistic mode.
*/
optimisticInitial?: TOptimistic
+
+ /**
+ * Alias for `optimisticData`.
+ * @deprecated Use `optimisticData` instead.
+ */
+ optimisticReducer?: OptimisticReducer
+
+ /**
+ * Alias for `optimisticInitial`.
+ * @deprecated Use `optimisticInitial` instead.
+ */
+ optimisticDefault?: TOptimistic
}
// ---------------------------------------------------------------------------
@@ -265,8 +278,18 @@ export interface ActionFormState {
isSubmitSuccessful: boolean
/** Raw error record returned by the server action (via errorMapper). */
submitErrors: FieldErrorRecord | null
- /** The full result from the last action invocation, if any. */
+ /** Full result from the last action invocation, if any. */
actionResult: TResult | null
+ /**
+ * Alias for `submitErrors`.
+ * @deprecated Use `submitErrors` instead.
+ */
+ serverErrors: FieldErrorRecord | null
+ /**
+ * Alias for `actionResult`.
+ * @deprecated Use `actionResult` instead.
+ */
+ lastResult: TResult | null
/**
* `true` while a transition is pending (React 19 `useTransition`).
* Falls back to `isSubmitting` on React 18.
@@ -302,6 +325,12 @@ export interface UseActionFormReturn<
*/
setSubmitError: (field: keyof TFieldValues & string, message: string) => void
+ /**
+ * Alias for `setSubmitError`.
+ * @deprecated Use `setSubmitError` instead.
+ */
+ setServerError: (field: keyof TFieldValues & string, message: string) => void
+
/**
* Manually persist the current form state to sessionStorage.
* Only works when `persistKey` is set.
@@ -314,6 +343,12 @@ export interface UseActionFormReturn<
*/
clearPersistedData: () => void
+ /**
+ * Alias for `clearPersistedData`.
+ * @deprecated Use `clearPersistedData` instead.
+ */
+ clearPersisted: () => void
+
/**
* The underlying form action compatible with Next.js ``.
*/
diff --git a/packages/core/src/use-action-form-core.ts b/packages/core/src/use-action-form-core.ts
index 573f2c4..dc1f8e2 100644
--- a/packages/core/src/use-action-form-core.ts
+++ b/packages/core/src/use-action-form-core.ts
@@ -93,13 +93,20 @@ export function useActionFormCore<
onError,
persistDebounce = 300,
schema: optionsSchema,
- validationMode = 'onSubmit',
+ validationMode,
+ clientValidation,
optimisticKey,
optimisticData,
optimisticInitial,
+ optimisticReducer,
+ optimisticDefault,
plugins = [],
} = options
+ const resolvedValidationMode = validationMode ?? clientValidation ?? 'onSubmit'
+ const resolvedOptimisticData = optimisticData ?? optimisticReducer
+ const resolvedOptimisticInitial = (optimisticInitial ?? optimisticDefault) as TOptimistic
+
// ----- Resolve Zod schema -----------------------------------------------
const resolvedSchema = useMemo(() => optionsSchema ?? undefined, [optionsSchema])
@@ -138,6 +145,8 @@ export function useActionFormCore<
isSubmitSuccessful: false,
submitErrors: null,
actionResult: null,
+ serverErrors: null,
+ lastResult: null,
isPending: false,
})
@@ -147,14 +156,15 @@ export function useActionFormCore<
// ----- Optimistic UI (React 19 only) -------------------------------------
- const hasOptimistic = optimisticKey != null && optimisticData != null
+ const hasOptimistic =
+ resolvedOptimisticData != null && (optimisticKey != null || optimisticReducer != null)
const useOptimisticHook =
hasUseOptimistic && useOptimisticReact19 ? useOptimisticReact19 : useOptimisticFallback
- const [optimisticState, setOptimistic] = useOptimisticHook(optimisticInitial as TOptimistic)
+ const [optimisticState, setOptimistic] = useOptimisticHook(resolvedOptimisticInitial)
- const confirmedOptimisticRef = useRef(optimisticInitial as TOptimistic)
+ const confirmedOptimisticRef = useRef(resolvedOptimisticInitial)
const rollbackOptimistic = useCallback(() => {
setOptimistic(confirmedOptimisticRef.current)
@@ -182,12 +192,12 @@ export function useActionFormCore<
// ----- Client-side Zod validation (onChange / onBlur) ---------------------
useEffect(() => {
- if (!resolvedSchema || validationMode === 'onSubmit') return
+ if (!resolvedSchema || resolvedValidationMode === 'onSubmit') return
const subscription = form.watch((values, { name, type }) => {
if (!name) return
- if (validationMode === 'onChange' || type === 'blur') {
+ if (resolvedValidationMode === 'onChange' || type === 'blur') {
const fieldResult = resolvedSchema.safeParse(values)
if (fieldResult.success) {
form.clearErrors(name as FieldPath)
@@ -209,7 +219,7 @@ export function useActionFormCore<
})
return () => subscription.unsubscribe()
- }, [resolvedSchema, validationMode, form])
+ }, [resolvedSchema, resolvedValidationMode, form])
// ----- Plugin lifecycle: onMount -----------------------------------------
@@ -231,11 +241,13 @@ export function useActionFormCore<
savePersistedValues(persistKey, form.getValues())
}, [persistKey, form])
- const clearPersisted = useCallback(() => {
+ const clearPersistedData = useCallback(() => {
if (!persistKey) return
clearPersistedValues(persistKey)
}, [persistKey])
+ const clearPersisted = clearPersistedData
+
// ----- Set a server error on a field -------------------------------------
const setSubmitError = useCallback(
@@ -245,6 +257,8 @@ export function useActionFormCore<
[form],
)
+ const setServerError = setSubmitError
+
// ----- Map server errors to RHF ------------------------------------------
const applyServerErrors = useCallback(
@@ -266,7 +280,7 @@ export function useActionFormCore<
const executeSubmit = useCallback(
async (data: TFieldValues) => {
// Client-side schema validation (for onSubmit mode)
- if (resolvedSchema && validationMode === 'onSubmit') {
+ if (resolvedSchema && resolvedValidationMode === 'onSubmit') {
const clientErrors = validateWithSchema(resolvedSchema, data as Record)
if (clientErrors) {
applyServerErrors(clientErrors)
@@ -275,6 +289,8 @@ export function useActionFormCore<
isSubmitSuccessful: false,
submitErrors: clientErrors,
actionResult: null,
+ serverErrors: clientErrors,
+ lastResult: null,
isPending: false,
})
return
@@ -294,11 +310,12 @@ export function useActionFormCore<
isSubmitting: true,
isPending: true,
submitErrors: null,
+ serverErrors: null,
}))
// Apply optimistic update before the action runs
- if (hasOptimistic && optimisticData) {
- const optimisticResult = optimisticData(confirmedOptimisticRef.current, data)
+ if (hasOptimistic && resolvedOptimisticData) {
+ const optimisticResult = resolvedOptimisticData(confirmedOptimisticRef.current, data)
setOptimistic(optimisticResult)
}
@@ -333,6 +350,8 @@ export function useActionFormCore<
isSubmitSuccessful: false,
submitErrors: fieldErrors,
actionResult: result,
+ serverErrors: fieldErrors,
+ lastResult: result,
isPending: false,
})
@@ -344,8 +363,11 @@ export function useActionFormCore<
onError?.(result)
} else {
// Success – update confirmed optimistic state
- if (hasOptimistic && optimisticData) {
- confirmedOptimisticRef.current = optimisticData(confirmedOptimisticRef.current, data)
+ if (hasOptimistic && resolvedOptimisticData) {
+ confirmedOptimisticRef.current = resolvedOptimisticData(
+ confirmedOptimisticRef.current,
+ data,
+ )
}
// Clear persisted data
@@ -356,6 +378,8 @@ export function useActionFormCore<
isSubmitSuccessful: true,
submitErrors: null,
actionResult: result,
+ serverErrors: null,
+ lastResult: result,
isPending: false,
})
@@ -408,9 +432,9 @@ export function useActionFormCore<
onError,
persistKey,
resolvedSchema,
- validationMode,
+ resolvedValidationMode,
hasOptimistic,
- optimisticData,
+ resolvedOptimisticData,
setOptimistic,
rollbackOptimistic,
plugins,
@@ -483,8 +507,10 @@ export function useActionFormCore<
handleSubmit,
formState: composedFormState,
setSubmitError,
+ setServerError,
persist,
- clearPersistedData: clearPersisted,
+ clearPersistedData,
+ clearPersisted,
optimistic: optimisticReturn,
} as UseActionFormCoreReturn
}
diff --git a/packages/core/src/use-action-form.ts b/packages/core/src/use-action-form.ts
index 6e9c619..0343079 100644
--- a/packages/core/src/use-action-form.ts
+++ b/packages/core/src/use-action-form.ts
@@ -122,12 +122,19 @@ export function useActionForm<
onError,
persistDebounce = 300,
schema: optionsSchema,
- validationMode = 'onSubmit',
+ validationMode,
+ clientValidation,
optimisticKey,
optimisticData,
optimisticInitial,
+ optimisticReducer,
+ optimisticDefault,
} = options
+ const resolvedValidationMode = validationMode ?? clientValidation ?? 'onSubmit'
+ const resolvedOptimisticData = optimisticData ?? optimisticReducer
+ const resolvedOptimisticInitial = (optimisticInitial ?? optimisticDefault) as TOptimistic
+
// ----- Resolve Zod schema (explicit or auto-detected from withZod) -------
const resolvedSchema = useMemo(
@@ -169,6 +176,8 @@ export function useActionForm<
isSubmitSuccessful: false,
submitErrors: null,
actionResult: null,
+ serverErrors: null,
+ lastResult: null,
isPending: false,
})
@@ -179,16 +188,17 @@ export function useActionForm<
// ----- Optimistic UI (React 19 only) -------------------------------------
- const hasOptimistic = optimisticKey != null && optimisticData != null
+ const hasOptimistic =
+ resolvedOptimisticData != null && (optimisticKey != null || optimisticReducer != null)
// We use the React 19 useOptimistic when available, otherwise a simple state fallback.
const useOptimisticHook =
hasUseOptimistic && useOptimisticReact19 ? useOptimisticReact19 : useOptimisticFallback
- const [optimisticState, setOptimistic] = useOptimisticHook(optimisticInitial as TOptimistic)
+ const [optimisticState, setOptimistic] = useOptimisticHook(resolvedOptimisticInitial)
// Track confirmed state for rollback
- const confirmedOptimisticRef = useRef(optimisticInitial as TOptimistic)
+ const confirmedOptimisticRef = useRef(resolvedOptimisticInitial)
const rollbackOptimistic = useCallback(() => {
setOptimistic(confirmedOptimisticRef.current)
@@ -217,14 +227,14 @@ export function useActionForm<
// ----- Client-side Zod validation (onChange / onBlur) ---------------------
useEffect(() => {
- if (!resolvedSchema || validationMode === 'onSubmit') return
+ if (!resolvedSchema || resolvedValidationMode === 'onSubmit') return
const subscription = form.watch((values, { name, type }) => {
if (!name) return
// For onBlur mode, only validate if the event type is blur
// (RHF watch fires on all changes, so we validate on onChange mode always)
- if (validationMode === 'onChange' || type === 'blur') {
+ if (resolvedValidationMode === 'onChange' || type === 'blur') {
// Validate the single changed field
const fieldResult = resolvedSchema.safeParse(values)
if (fieldResult.success) {
@@ -248,7 +258,7 @@ export function useActionForm<
})
return () => subscription.unsubscribe()
- }, [resolvedSchema, validationMode, form])
+ }, [resolvedSchema, resolvedValidationMode, form])
// ----- Manual persist / clear --------------------------------------------
@@ -257,11 +267,13 @@ export function useActionForm<
savePersistedValues(persistKey, form.getValues())
}, [persistKey, form])
- const clearPersisted = useCallback(() => {
+ const clearPersistedData = useCallback(() => {
if (!persistKey) return
clearPersistedValues(persistKey)
}, [persistKey])
+ const clearPersisted = clearPersistedData
+
// ----- Set a server error on a field -------------------------------------
const setSubmitError = useCallback(
@@ -271,6 +283,8 @@ export function useActionForm<
[form],
)
+ const setServerError = setSubmitError
+
// ----- Map server errors to RHF ------------------------------------------
const applyServerErrors = useCallback(
@@ -297,7 +311,7 @@ export function useActionForm<
const executeAction = useCallback(
async (data: TFieldValues | FormData, isFormDataSubmission: boolean) => {
// Client-side schema validation (for onSubmit mode)
- if (resolvedSchema && validationMode === 'onSubmit' && !(data instanceof FormData)) {
+ if (resolvedSchema && resolvedValidationMode === 'onSubmit' && !(data instanceof FormData)) {
const clientErrors = validateWithSchema(resolvedSchema, data as Record)
if (clientErrors) {
applyServerErrors(clientErrors)
@@ -306,6 +320,8 @@ export function useActionForm<
isSubmitSuccessful: false,
submitErrors: clientErrors,
actionResult: null,
+ serverErrors: clientErrors,
+ lastResult: null,
isPending: false,
})
return
@@ -317,11 +333,12 @@ export function useActionForm<
isSubmitting: true,
isPending: true,
submitErrors: null,
+ serverErrors: null,
}))
// Apply optimistic update before the action runs
- if (hasOptimistic && optimisticData && !(data instanceof FormData)) {
- const optimisticResult = optimisticData(
+ if (hasOptimistic && resolvedOptimisticData && !(data instanceof FormData)) {
+ const optimisticResult = resolvedOptimisticData(
confirmedOptimisticRef.current,
data as TFieldValues,
)
@@ -396,13 +413,15 @@ export function useActionForm<
isSubmitSuccessful: false,
submitErrors: fieldErrors,
actionResult: result,
+ serverErrors: fieldErrors,
+ lastResult: result,
isPending: false,
})
onError?.(result)
} else {
// Success – update confirmed optimistic state
- if (hasOptimistic && optimisticData && !(data instanceof FormData)) {
- confirmedOptimisticRef.current = optimisticData(
+ if (hasOptimistic && resolvedOptimisticData && !(data instanceof FormData)) {
+ confirmedOptimisticRef.current = resolvedOptimisticData(
confirmedOptimisticRef.current,
data as TFieldValues,
)
@@ -416,6 +435,8 @@ export function useActionForm<
isSubmitSuccessful: true,
submitErrors: null,
actionResult: result,
+ serverErrors: null,
+ lastResult: result,
isPending: false,
})
onSuccess?.(result)
@@ -444,9 +465,9 @@ export function useActionForm<
onError,
persistKey,
resolvedSchema,
- validationMode,
+ resolvedValidationMode,
hasOptimistic,
- optimisticData,
+ resolvedOptimisticData,
setOptimistic,
rollbackOptimistic,
],
@@ -520,8 +541,10 @@ export function useActionForm<
handleSubmit,
formState: composedFormState,
setSubmitError,
+ setServerError,
persist,
- clearPersistedData: clearPersisted,
+ clearPersistedData,
+ clearPersisted,
formAction,
optimistic: optimisticReturn,
} as UseActionFormReturn
diff --git a/packages/devtools/package.json b/packages/devtools/package.json
index 893a79c..e3fdef6 100644
--- a/packages/devtools/package.json
+++ b/packages/devtools/package.json
@@ -1,7 +1,7 @@
{
"name": "hookform-action-devtools",
"version": "4.0.3",
- "description": "DevTools panel for hookform-action – inspect form state, submission history, and debug optimistic UI.",
+ "description": "DevTools panel for hookform-action typed submit flows: inspect form state, submissions, and optimistic UI behavior.",
"keywords": ["react-hook-form", "devtools", "form", "debug", "inspector"],
"license": "MIT",
"author": "",
diff --git a/packages/devtools/src/FormDevTool.tsx b/packages/devtools/src/FormDevTool.tsx
index c346f3b..3da7f8b 100644
--- a/packages/devtools/src/FormDevTool.tsx
+++ b/packages/devtools/src/FormDevTool.tsx
@@ -360,11 +360,11 @@ export function FormDevTool({
✓ No errors
)}
- {/* Submit Errors (from server) */}
- {actionFormState?.submitErrors && (
+ {/* Server Errors */}
+ {actionFormState?.serverErrors && (
<>
Server Errors
- {Object.entries(actionFormState.submitErrors).map(([key, messages]) => (
+ {Object.entries(actionFormState.serverErrors).map(([key, messages]) => (
{key}
@@ -473,7 +473,7 @@ export function FormDevTool({
}}
onClick={() => {
console.log('[FormDevTool] Errors:', formState.errors)
- console.log('[FormDevTool] Server errors:', actionFormState?.submitErrors)
+ console.log('[FormDevTool] Server errors:', actionFormState?.serverErrors)
}}
title="Log all errors to console"
>
diff --git a/packages/next/README.md b/packages/next/README.md
index 26d8213..9db93cc 100644
--- a/packages/next/README.md
+++ b/packages/next/README.md
@@ -1,6 +1,6 @@
# ⚡ hookform-action
-Seamless integration between **React Hook Form** and **Next.js Server Actions** with Zod validation, automatic type inference, optimistic UI, multi-step persistence, and DevTools.
+Typed submit flows between **React Hook Form** and **Next.js Server Actions**. Write the schema. Write the action. Skip the wiring.
[](https://www.npmjs.com/package/hookform-action)
[](https://www.npmjs.com/package/hookform-action)
@@ -69,17 +69,58 @@ export function LoginForm() {
}
```
-## Features
+## What you stop writing
-- 🔒 **Full Type Inference** — Types inferred from your action automatically
-- ⚡ **Auto Error Mapping** — Zod `.flatten().fieldErrors` mapped to RHF fields out of the box
-- 🚀 **Optimistic UI** — Native `useOptimistic` integration with automatic rollback
-- 🔍 **Client-Side Validation** — Real-time Zod validation (`onChange`/`onBlur`/`onSubmit`)
-- 💾 **Wizard Persistence** — Multi-step form state saved to sessionStorage with debounce
-- 🧩 **Headless ``** — Optional wrapper providing FormContext to children
+- ❌ 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 action to every field, error, and return value
+- ⚡ **Auto Error Mapping** — Zod `fieldErrors` mapped to RHF fields automatically, server and client
+- 🚀 **Optimistic UI** — `useOptimistic` wired in one option, with automatic rollback
+- 🔍 **Client-Side Validation** — Real-time Zod validation (`onChange` / `onBlur` / `onSubmit`)
+- 💾 **Wizard Persistence** — Multi-step state persisted to sessionStorage, debounced and SSR-safe
+- 🧩 **Headless ` `** — Optional context wrapper for complex form trees
- 📦 **Tiny Bundle** — ESM + CJS, tree-shakeable, peer deps only
- 🧪 **81+ Tests** — Vitest + React Testing Library
+## Mental Model
+
+`hookform-action` is the glue between **React Hook Form** and your **Next.js Server Action**, with Zod living in exactly one place.
+
+**Your schema lives once — `withZod`**
+
+`withZod(schema, handler)` validates data on the server _and_ silently attaches the schema to the returned function (`action.__schema`). `useActionForm` detects it automatically for client-side real-time validation — no need to pass the schema twice.
+
+**The hook wires the submit flow**
+
+When the user submits, `handleSubmit()` runs this pipeline:
+
+```
+handleSubmit()
+ → client Zod validation → server action (inside useTransition) → isPending = true
+ → result.errors mapped back to RHF fields
+ → onSuccess / onError fired
+```
+
+You receive the full RHF API — `register`, `watch`, `setValue`, `formState.errors` — plus `isPending` and `optimistic`.
+
+**Features are strictly opt-in**
+
+The base `useActionForm` call has no extra behavior enabled. You opt in precisely:
+
+| Feature | How to enable |
+| --------------------------- | ---------------------------------- |
+| Real-time client validation | `validationMode: 'onChange'` |
+| Optimistic UI | `optimisticKey` + `optimisticData` |
+| Multi-step persistence | `persistKey` |
+| DevTools panel | ` ` |
+
## API
### `useActionForm(action, options?)`
diff --git a/packages/next/package.json b/packages/next/package.json
index 46d7f89..26e30b8 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -1,7 +1,7 @@
{
"name": "hookform-action",
"version": "4.0.3",
- "description": "Next.js adapter for hookform-action – bridges React Hook Form with Server Actions.",
+ "description": "Typed submit flows for React Hook Form + Next.js Server Actions, with Zod mapping, optimistic UI, persistence, and DevTools.",
"keywords": ["next.js", "react-hook-form", "server-actions", "form", "validation", "typescript"],
"license": "MIT",
"author": "",
diff --git a/packages/standalone/README.md b/packages/standalone/README.md
index 200079b..286285a 100644
--- a/packages/standalone/README.md
+++ b/packages/standalone/README.md
@@ -1,6 +1,6 @@
# hookform-action-standalone
-Standalone React adapter for **hookform-action** — use the same API without Next.js. Works with **Vite**, **Remix**, **Astro**, or any React SPA.
+Typed submit flows for **React Hook Form** in **Vite**, **Remix**, **Astro**, and any React SPA. Same API, no Server Actions required.
[](https://www.npmjs.com/package/hookform-action-standalone)
[](https://www.npmjs.com/package/hookform-action-standalone)
@@ -60,6 +60,22 @@ export function LoginForm() {
}
```
+## Mental Model
+
+`hookform-action-standalone` gives you the same `useActionForm` API as the Next.js adapter — without any framework requirement. Instead of passing a Server Action, you pass a `submit` function (fetch, Axios, tRPC, or anything async).
+
+The submit flow is identical to the Next.js adapter:
+
+```
+handleSubmit()
+ → client-side Zod validation (if schema provided)
+ → your submit function called → isPending = true
+ → result errors mapped back to RHF fields
+ → onSuccess / onError fired
+```
+
+All optional features — real-time validation (`validationMode`), optimistic UI (`optimisticKey`), multi-step persistence (`persistKey`) — work exactly the same as in `hookform-action`.
+
## API
### `useActionForm(options)`
diff --git a/packages/standalone/package.json b/packages/standalone/package.json
index f925b03..b6787ff 100644
--- a/packages/standalone/package.json
+++ b/packages/standalone/package.json
@@ -1,7 +1,7 @@
{
"name": "hookform-action-standalone",
"version": "4.0.3",
- "description": "Standalone React adapter for hookform-action – use the same API without Next.js (Vite, Remix, Astro, SPAs).",
+ "description": "Standalone typed submit flows for React Hook Form with Zod mapping, optimistic UI, persistence, and DevTools.",
"keywords": [
"react",
"react-hook-form",
diff --git a/packages/standalone/src/__tests__/use-action-form.test.ts b/packages/standalone/src/__tests__/use-action-form.test.ts
index 1a29000..946e2fb 100644
--- a/packages/standalone/src/__tests__/use-action-form.test.ts
+++ b/packages/standalone/src/__tests__/use-action-form.test.ts
@@ -45,9 +45,9 @@ describe('hookform-action-standalone – useActionForm', () => {
expect(result.current.register).toBeDefined()
expect(result.current.handleSubmit).toBeDefined()
expect(result.current.formState).toBeDefined()
- expect(result.current.setSubmitError).toBeDefined()
+ expect(result.current.setServerError).toBeDefined()
expect(result.current.persist).toBeDefined()
- expect(result.current.clearPersistedData).toBeDefined()
+ expect(result.current.clearPersisted).toBeDefined()
})
it('does NOT have formAction (standalone has no Server Actions)', () => {
@@ -121,7 +121,7 @@ describe('hookform-action-standalone – useActionForm', () => {
})
await waitFor(() => {
- expect(result.current.formState.submitErrors).toEqual({
+ expect(result.current.formState.serverErrors).toEqual({
email: ['Email already exists'],
})
})
@@ -192,15 +192,14 @@ describe('hookform-action-standalone – useActionForm', () => {
useActionForm({
submit,
defaultValues: { title: 'New Todo' },
- optimisticKey: 'todo-1',
- optimisticData: (
+ optimisticReducer: (
current: Record,
formValues: Record,
) => ({
...current,
title: formValues.title,
}),
- optimisticInitial: { title: 'Old Todo' },
+ optimisticDefault: { title: 'Old Todo' },
}),
)