-
🌍
-
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..c897afc
--- /dev/null
+++ b/apps/docs/app/recipes/custom-error-mapper/page.tsx
@@ -0,0 +1,307 @@
+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..8c4e50f
--- /dev/null
+++ b/apps/docs/app/recipes/edit-server-data/page.tsx
@@ -0,0 +1,273 @@
+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..ec138ca
--- /dev/null
+++ b/apps/docs/app/recipes/field-array/page.tsx
@@ -0,0 +1,306 @@
+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..d7a097b
--- /dev/null
+++ b/apps/docs/app/recipes/file-upload/page.tsx
@@ -0,0 +1,308 @@
+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..2d6daeb
--- /dev/null
+++ b/apps/docs/app/recipes/login-form/page.tsx
@@ -0,0 +1,240 @@
+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..f800245
--- /dev/null
+++ b/apps/docs/app/recipes/modal-form/page.tsx
@@ -0,0 +1,363 @@
+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..5f2bc31
--- /dev/null
+++ b/apps/docs/app/recipes/multi-step-wizard/page.tsx
@@ -0,0 +1,351 @@
+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..fea46a1
--- /dev/null
+++ b/apps/docs/app/recipes/nested-fields/page.tsx
@@ -0,0 +1,339 @@
+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..8b138a9
--- /dev/null
+++ b/apps/docs/app/recipes/optimistic-ui/page.tsx
@@ -0,0 +1,281 @@
+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..d81f799
--- /dev/null
+++ b/apps/docs/app/recipes/page.tsx
@@ -0,0 +1,253 @@
+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..0ff180d
--- /dev/null
+++ b/apps/docs/app/recipes/reset-after-success/page.tsx
@@ -0,0 +1,301 @@
+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..017898a
--- /dev/null
+++ b/apps/docs/app/recipes/signup-server-errors/page.tsx
@@ -0,0 +1,279 @@
+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..afb7011
--- /dev/null
+++ b/apps/docs/app/recipes/standalone-fetch/page.tsx
@@ -0,0 +1,336 @@
+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..0f6b017
--- /dev/null
+++ b/apps/docs/app/submit-lifecycle/page.tsx
@@ -0,0 +1,229 @@
+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..16a0b58
--- /dev/null
+++ b/apps/docs/app/why/page.tsx
@@ -0,0 +1,415 @@
+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'}
+
+
+ )
+}`}
+
+
+
+
+
+ {/* 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..34e7b07 100644
--- a/packages/core/src/__tests__/client-validation.test.ts
+++ b/packages/core/src/__tests__/client-validation.test.ts
@@ -1,276 +1,276 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
-import { z } from 'zod'
-import type { JsonServerAction } from '../types'
-import { useActionForm } from '../use-action-form'
-import { withZod } from '../with-zod'
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { z } from "zod";
+import type { JsonServerAction } from "../types";
+import { useActionForm } from "../use-action-form";
+import { withZod } from "../with-zod";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const signupSchema = z.object({
- email: z.string().email('Invalid email address'),
- password: z.string().min(8, 'Password must be at least 8 characters'),
-})
+ email: z.string().email("Invalid email address"),
+ password: z.string().min(8, "Password must be at least 8 characters"),
+});
/** JSON action that always succeeds */
function createSuccessAction(): JsonServerAction<{ success: true }> {
- return vi.fn(async (_data: unknown) => ({ success: true as const }))
+ return vi.fn(async (_data: unknown) => ({ success: true as const }));
}
// ---------------------------------------------------------------------------
// Tests: Explicit schema validation
// ---------------------------------------------------------------------------
-describe('useActionForm – client-side Zod validation (explicit schema)', () => {
- it('validates on submit by default and blocks submission on error', async () => {
- const action = createSuccessAction()
+describe("useActionForm – client-side Zod validation (explicit schema)", () => {
+ it("validates on submit by default and blocks submission on error", async () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'invalid', password: 'short' },
+ defaultValues: { email: "invalid", password: "short" },
schema: signupSchema,
- validationMode: 'onSubmit',
+ clientValidation: "onSubmit",
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
// Action should NOT have been called because client validation failed
- expect(action).not.toHaveBeenCalled()
+ expect(action).not.toHaveBeenCalled();
// Errors should be set
- expect(result.current.formState.submitErrors).not.toBeNull()
- })
- })
+ expect(result.current.formState.serverErrors).not.toBeNull();
+ });
+ });
- it('allows submission when client validation passes', async () => {
- const action = createSuccessAction()
+ it("allows submission when client validation passes", async () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'valid@test.com', password: '12345678' },
+ defaultValues: { email: "valid@test.com", password: "12345678" },
schema: signupSchema,
- validationMode: 'onSubmit',
+ clientValidation: "onSubmit",
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(action).toHaveBeenCalled()
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- })
- })
+ expect(action).toHaveBeenCalled();
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ });
+ });
- it('sets specific field errors from client validation', async () => {
- const action = createSuccessAction()
+ it("sets specific field errors from client validation", async () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'bad', password: '12345678' },
+ defaultValues: { email: "bad", password: "12345678" },
schema: signupSchema,
- validationMode: 'onSubmit',
+ clientValidation: "onSubmit",
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
// Only email should have an error
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Invalid email address')
- })
- })
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Invalid email address");
+ });
+ });
- it('validates onChange when validationMode is onChange', async () => {
- const action = createSuccessAction()
+ it("validates onChange when clientValidation is onChange", async () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '', password: '' },
+ defaultValues: { email: "", password: "" },
schema: signupSchema,
- validationMode: 'onChange',
+ clientValidation: "onChange",
}),
- )
+ );
// Trigger a change with invalid value
act(() => {
- result.current.setValue('email', 'bad-email', { shouldDirty: true })
- })
+ result.current.setValue("email", "bad-email", { shouldDirty: true });
+ });
// Wait for the watch subscription to fire
await waitFor(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Invalid email address')
- })
- })
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Invalid email address");
+ });
+ });
- it('clears errors when field becomes valid on onChange', async () => {
- const action = createSuccessAction()
+ it("clears errors when field becomes valid on onChange", async () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '', password: '' },
+ defaultValues: { email: "", password: "" },
schema: signupSchema,
- validationMode: 'onChange',
+ clientValidation: "onChange",
}),
- )
+ );
// Set invalid value
act(() => {
- result.current.setValue('email', 'bad', { shouldDirty: true })
- })
+ result.current.setValue("email", "bad", { shouldDirty: true });
+ });
await waitFor(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Invalid email address')
- })
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Invalid email address");
+ });
// Set valid value
act(() => {
- result.current.setValue('email', 'valid@test.com', { shouldDirty: true })
- })
+ result.current.setValue("email", "valid@test.com", { shouldDirty: true });
+ });
await waitFor(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error).toBeUndefined()
- })
- })
-})
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error).toBeUndefined();
+ });
+ });
+});
// ---------------------------------------------------------------------------
// Tests: Auto-detected schema from withZod
// ---------------------------------------------------------------------------
-describe('useActionForm – auto-detected schema from withZod', () => {
- it('withZod attaches __schema to the action', () => {
- const action = withZod(signupSchema, async (_data) => ({ success: true as const }))
- expect(action.__schema).toBe(signupSchema)
- })
+describe("useActionForm – auto-detected schema from withZod", () => {
+ it("withZod attaches __schema to the action", () => {
+ const action = withZod(signupSchema, async (_data) => ({ success: true as const }));
+ expect(action.__schema).toBe(signupSchema);
+ });
- it('auto-detects schema from withZod action and validates on submit', async () => {
+ it("auto-detects schema from withZod action and validates on submit", async () => {
const handler = vi.fn(async (data: unknown) => ({
success: true as const,
data,
- }))
- const action = withZod(signupSchema, handler)
+ }));
+ const action = withZod(signupSchema, handler);
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'invalid', password: 'short' },
+ defaultValues: { email: "invalid", password: "short" },
// No explicit schema – should auto-detect from action.__schema
- validationMode: 'onSubmit',
+ clientValidation: "onSubmit",
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
// Handler should NOT have been called since client validation fails
- expect(handler).not.toHaveBeenCalled()
- expect(result.current.formState.submitErrors).not.toBeNull()
- })
- })
+ expect(handler).not.toHaveBeenCalled();
+ expect(result.current.formState.serverErrors).not.toBeNull();
+ });
+ });
- it('auto-detected schema validates onChange', async () => {
+ it("auto-detected schema validates onChange", async () => {
const action = withZod(signupSchema, async (_data) => ({
success: true as const,
- }))
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '', password: '' },
+ defaultValues: { email: "", password: "" },
// Auto-detects schema, validates on change
- validationMode: 'onChange',
+ clientValidation: "onChange",
}),
- )
+ );
act(() => {
- result.current.setValue('email', 'bad', { shouldDirty: true })
- })
+ result.current.setValue("email", "bad", { shouldDirty: true });
+ });
await waitFor(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Invalid email address')
- })
- })
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Invalid email address");
+ });
+ });
- it('explicit schema takes priority over auto-detected', async () => {
+ it("explicit schema takes priority over auto-detected", async () => {
const customSchema = z.object({
- email: z.string().email('Custom error message'),
+ email: z.string().email("Custom error message"),
password: z.string().min(1),
- })
+ });
const action = withZod(signupSchema, async (_data) => ({
success: true as const,
- }))
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'bad', password: '12345678' },
+ defaultValues: { email: "bad", password: "12345678" },
schema: customSchema, // This should take priority
- validationMode: 'onSubmit',
+ clientValidation: "onSubmit",
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Custom error message') // From custom schema, not withZod
- })
- })
-})
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Custom error message"); // From custom schema, not withZod
+ });
+ });
+});
// ---------------------------------------------------------------------------
// Tests: isPending state (useTransition integration)
// ---------------------------------------------------------------------------
-describe('useActionForm – isPending / useTransition', () => {
- it('formState includes isPending property', () => {
- const action = createSuccessAction()
+describe("useActionForm – isPending / useTransition", () => {
+ it("formState includes isPending property", () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
}),
- )
+ );
- expect(result.current.formState.isPending).toBe(false)
- })
+ expect(result.current.formState.isPending).toBe(false);
+ });
- it('isPending resolves to false after successful submission', async () => {
- const action = createSuccessAction()
+ it("isPending resolves to false after successful submission", async () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'test@test.com', password: '12345678' },
+ defaultValues: { email: "test@test.com", password: "12345678" },
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isPending).toBe(false)
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- })
- })
-})
+ expect(result.current.formState.isPending).toBe(false);
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ });
+ });
+});
diff --git a/packages/core/src/__tests__/optimistic.test.ts b/packages/core/src/__tests__/optimistic.test.ts
index 76388d2..7ea09a3 100644
--- a/packages/core/src/__tests__/optimistic.test.ts
+++ b/packages/core/src/__tests__/optimistic.test.ts
@@ -1,209 +1,204 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
-import type { JsonServerAction } from '../types'
-import { useActionForm } from '../use-action-form'
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { JsonServerAction } from "../types";
+import { useActionForm } from "../use-action-form";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
interface TodoItem {
- id: string
- title: string
- completed: boolean
+ id: string;
+ title: string;
+ completed: boolean;
}
/** JSON action that succeeds – simulates updating a todo */
function createUpdateTodoAction(): JsonServerAction<{ success: true; data: TodoItem }> {
return vi.fn(async (data: unknown) => {
- const d = data as Record
+ const d = data as Record;
return {
success: true as const,
data: {
- id: (d.id as string) ?? 'todo-1',
+ id: (d.id as string) ?? "todo-1",
title: d.title as string,
completed: (d.completed as boolean) ?? false,
},
- }
- })
+ };
+ });
}
/** JSON action that returns errors */
function createErrorTodoAction(): JsonServerAction<{ errors: { title: string[] } }> {
return vi.fn(async (_data: unknown) => ({
- errors: { title: ['Title is required'] },
- }))
+ errors: { title: ["Title is required"] },
+ }));
}
/** JSON action that throws */
function createThrowingTodoAction(): JsonServerAction<{ success: true }> {
return vi.fn(async (_data: unknown) => {
- throw new Error('Network error')
- })
+ throw new Error("Network error");
+ });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
-describe('useActionForm – optimistic updates', () => {
+describe("useActionForm – optimistic updates", () => {
const INITIAL_TODO: TodoItem = {
- id: 'todo-1',
- title: 'Buy groceries',
+ id: "todo-1",
+ title: "Buy groceries",
completed: false,
- }
+ };
- it('returns optimistic state when optimisticKey and optimisticData are provided', () => {
- const action = createUpdateTodoAction()
+ 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) => ({
+ defaultValues: { title: "Buy groceries", completed: false },
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
completed: formValues.completed,
}),
}),
- )
+ );
- expect(result.current.optimistic).toBeDefined()
- expect(result.current.optimistic?.data).toEqual(INITIAL_TODO)
- expect(result.current.optimistic?.isPending).toBe(false)
- expect(typeof result.current.optimistic?.rollback).toBe('function')
- })
+ expect(result.current.optimistic).toBeDefined();
+ expect(result.current.optimistic?.data).toEqual(INITIAL_TODO);
+ expect(result.current.optimistic?.isPending).toBe(false);
+ expect(typeof result.current.optimistic?.rollback).toBe("function");
+ });
- it('returns undefined optimistic when no optimisticKey is set', () => {
- const action = createUpdateTodoAction()
+ it("returns undefined optimistic when no optimisticReducer is set", () => {
+ const action = createUpdateTodoAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { title: '' },
+ defaultValues: { title: "" },
}),
- )
+ );
- expect(result.current.optimistic).toBeUndefined()
- })
+ expect(result.current.optimistic).toBeUndefined();
+ });
- it('updates optimistic data on successful submit', async () => {
- const action = createUpdateTodoAction()
+ it("updates optimistic data on successful submit", async () => {
+ const action = createUpdateTodoAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { title: 'Updated title', completed: true },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ defaultValues: { title: "Updated title", completed: true },
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
completed: formValues.completed,
}),
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ });
// After successful submit, the optimistic state should reflect the update
- expect(result.current.optimistic?.data.title).toBe('Updated title')
- expect(result.current.optimistic?.data.completed).toBe(true)
- })
+ expect(result.current.optimistic?.data.title).toBe("Updated title");
+ expect(result.current.optimistic?.data.completed).toBe(true);
+ });
- it('rolls back optimistic data on action error', async () => {
- const action = createErrorTodoAction()
+ it("rolls back optimistic data on action error", async () => {
+ const action = createErrorTodoAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { title: '' },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ defaultValues: { title: "" },
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
}),
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(false)
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(false);
+ });
// Should have rolled back to original
- expect(result.current.optimistic?.data.title).toBe('Buy groceries')
- })
+ expect(result.current.optimistic?.data.title).toBe("Buy groceries");
+ });
- it('rolls back optimistic data on action throw', async () => {
- const action = createThrowingTodoAction()
+ it("rolls back optimistic data on action throw", async () => {
+ const action = createThrowingTodoAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { title: 'Will fail' },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ defaultValues: { title: "Will fail" },
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
}),
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitting).toBe(false)
- })
+ expect(result.current.formState.isSubmitting).toBe(false);
+ });
// Should have rolled back to original
- expect(result.current.optimistic?.data.title).toBe('Buy groceries')
- })
+ expect(result.current.optimistic?.data.title).toBe("Buy groceries");
+ });
- it('manual rollback restores confirmed state', async () => {
- const action = createUpdateTodoAction()
+ it("manual rollback restores confirmed state", async () => {
+ const action = createUpdateTodoAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { title: 'Updated title', completed: false },
- optimisticKey: 'todo-1',
- optimisticInitial: INITIAL_TODO,
- optimisticData: (current, formValues) => ({
+ defaultValues: { title: "Updated title", completed: false },
+ optimisticDefault: INITIAL_TODO,
+ optimisticReducer: (current, formValues) => ({
...current,
title: formValues.title,
}),
}),
- )
+ );
// Submit successfully to update confirmed state
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ });
// Now manually rollback
act(() => {
- result.current.optimistic?.rollback()
- })
+ result.current.optimistic?.rollback();
+ });
// The confirmed state after success should be the updated value
- expect(result.current.optimistic?.data.title).toBe('Updated title')
- })
-})
+ expect(result.current.optimistic?.data.title).toBe("Updated title");
+ });
+});
diff --git a/packages/core/src/__tests__/persistence.test.ts b/packages/core/src/__tests__/persistence.test.ts
index 86236a9..2453b39 100644
--- a/packages/core/src/__tests__/persistence.test.ts
+++ b/packages/core/src/__tests__/persistence.test.ts
@@ -1,155 +1,155 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { loadPersistedValues, savePersistedValues } from '../persist'
-import type { ServerAction } from '../types'
-import { useActionForm } from '../use-action-form'
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { loadPersistedValues, savePersistedValues } from "../persist";
+import type { ServerAction } from "../types";
+import { useActionForm } from "../use-action-form";
// ---------------------------------------------------------------------------
// Persistence integration tests
// ---------------------------------------------------------------------------
-describe('useActionForm – persistence', () => {
- const PERSIST_KEY = 'wizard-step-1'
+describe("useActionForm – persistence", () => {
+ const PERSIST_KEY = "wizard-step-1";
beforeEach(() => {
- sessionStorage.clear()
- })
+ sessionStorage.clear();
+ });
- it('restores values from sessionStorage on mount', () => {
+ it("restores values from sessionStorage on mount", () => {
// Pre-populate storage
- savePersistedValues(PERSIST_KEY, { email: 'stored@test.com', name: 'Bob' })
+ savePersistedValues(PERSIST_KEY, { email: "stored@test.com", name: "Bob" });
const action: ServerAction<{ success: true }> = vi.fn(async () => ({
success: true as const,
- }))
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
persistKey: PERSIST_KEY,
- defaultValues: { email: '', name: '' },
+ defaultValues: { email: "", name: "" },
}),
- )
+ );
- expect(result.current.getValues('email')).toBe('stored@test.com')
- expect(result.current.getValues('name')).toBe('Bob')
- })
+ expect(result.current.getValues("email")).toBe("stored@test.com");
+ expect(result.current.getValues("name")).toBe("Bob");
+ });
- it('persists form values to sessionStorage when fields change', async () => {
+ it("persists form values to sessionStorage when fields change", async () => {
const action: ServerAction<{ success: true }> = vi.fn(async () => ({
success: true as const,
- }))
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
persistKey: PERSIST_KEY,
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
persistDebounce: 50, // shorter for tests
}),
- )
+ );
act(() => {
- result.current.setValue('email', 'typing@test.com')
- })
+ result.current.setValue("email", "typing@test.com");
+ });
// Wait for debounce
- await new Promise((r) => setTimeout(r, 100))
+ await new Promise((r) => setTimeout(r, 100));
- const stored = loadPersistedValues<{ email: string }>(PERSIST_KEY)
- expect(stored?.email).toBe('typing@test.com')
- })
+ const stored = loadPersistedValues<{ email: string }>(PERSIST_KEY);
+ expect(stored?.email).toBe("typing@test.com");
+ });
- it('clears sessionStorage after successful submission', async () => {
- savePersistedValues(PERSIST_KEY, { email: 'old@test.com' })
+ it("clears sessionStorage after successful submission", async () => {
+ savePersistedValues(PERSIST_KEY, { email: "old@test.com" });
const action: ServerAction<{ success: true }> = vi.fn(async () => ({
success: true as const,
- }))
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
persistKey: PERSIST_KEY,
- defaultValues: { email: 'old@test.com' },
+ defaultValues: { email: "old@test.com" },
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ });
- expect(loadPersistedValues(PERSIST_KEY)).toBeNull()
- })
+ expect(loadPersistedValues(PERSIST_KEY)).toBeNull();
+ });
- it('does NOT clear sessionStorage when submission has errors', async () => {
- savePersistedValues(PERSIST_KEY, { email: 'bad@test.com' })
+ it("does NOT clear sessionStorage when submission has errors", async () => {
+ savePersistedValues(PERSIST_KEY, { email: "bad@test.com" });
const action: ServerAction<{ errors: { email: string[] } }> = vi.fn(async () => ({
- errors: { email: ['Invalid'] },
- }))
+ errors: { email: ["Invalid"] },
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
persistKey: PERSIST_KEY,
- defaultValues: { email: 'bad@test.com' },
+ defaultValues: { email: "bad@test.com" },
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(false)
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(false);
+ });
// Storage should still exist
- expect(loadPersistedValues(PERSIST_KEY)).not.toBeNull()
- })
+ expect(loadPersistedValues(PERSIST_KEY)).not.toBeNull();
+ });
- it('manually persists via persist()', () => {
+ it("manually persists via persist()", () => {
const action: ServerAction<{ success: true }> = vi.fn(async () => ({
success: true as const,
- }))
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
persistKey: PERSIST_KEY,
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
}),
- )
+ );
act(() => {
- result.current.setValue('email', 'manual@test.com')
- result.current.persist()
- })
+ result.current.setValue("email", "manual@test.com");
+ result.current.persist();
+ });
- const stored = loadPersistedValues<{ email: string }>(PERSIST_KEY)
- expect(stored?.email).toBe('manual@test.com')
- })
+ const stored = loadPersistedValues<{ email: string }>(PERSIST_KEY);
+ expect(stored?.email).toBe("manual@test.com");
+ });
- it('clears persisted data via clearPersistedData()', () => {
- savePersistedValues(PERSIST_KEY, { email: 'clear@test.com' })
+ it("clears persisted data via clearPersisted()", () => {
+ savePersistedValues(PERSIST_KEY, { email: "clear@test.com" });
const action: ServerAction<{ success: true }> = vi.fn(async () => ({
success: true as const,
- }))
+ }));
const { result } = renderHook(() =>
useActionForm(action, {
persistKey: PERSIST_KEY,
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
}),
- )
+ );
act(() => {
- result.current.clearPersistedData()
- })
+ result.current.clearPersisted();
+ });
- expect(loadPersistedValues(PERSIST_KEY)).toBeNull()
- })
-})
+ 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..9a73016 100644
--- a/packages/core/src/__tests__/use-action-form-core.test.ts
+++ b/packages/core/src/__tests__/use-action-form-core.test.ts
@@ -1,340 +1,334 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type { SubmitFunction } from '../core-types'
-import { useActionFormCore } from '../use-action-form-core'
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { SubmitFunction } from "../core-types";
+import { useActionFormCore } from "../use-action-form-core";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
-function createSuccessSubmit(): SubmitFunction<
- Record,
- { success: true; data: string }
-> {
+function createSuccessSubmit(): SubmitFunction, { success: true; data: string }> {
return vi.fn(async (_data: Record) => ({
success: true as const,
- data: 'ok',
- }))
+ data: "ok",
+ }));
}
-function createErrorSubmit(): SubmitFunction<
- Record,
- { errors: { email: string[] } }
-> {
+function createErrorSubmit(): SubmitFunction, { errors: { email: string[] } }> {
return vi.fn(async (_data: Record) => ({
- errors: { email: ['Invalid email address'] },
- }))
+ errors: { email: ["Invalid email address"] },
+ }));
}
function createThrowingSubmit(): SubmitFunction, { success: true }> {
return vi.fn(async (_data: Record) => {
- throw new Error('Network failure')
- })
+ throw new Error("Network failure");
+ });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
-describe('useActionFormCore', () => {
+describe("useActionFormCore", () => {
beforeEach(() => {
- sessionStorage.clear()
- })
+ sessionStorage.clear();
+ });
// ---- Basic rendering ----------------------------------------------------
- it('returns all expected core properties', () => {
- const submit = createSuccessSubmit()
- const { result } = renderHook(() => useActionFormCore(submit))
+ it("returns all expected core properties", () => {
+ const submit = createSuccessSubmit();
+ const { result } = renderHook(() => useActionFormCore(submit));
- expect(result.current.register).toBeDefined()
- expect(result.current.handleSubmit).toBeDefined()
- expect(result.current.formState).toBeDefined()
- expect(result.current.setSubmitError).toBeDefined()
- expect(result.current.persist).toBeDefined()
- expect(result.current.clearPersistedData).toBeDefined()
- expect(result.current.control).toBeDefined()
- })
+ expect(result.current.register).toBeDefined();
+ expect(result.current.handleSubmit).toBeDefined();
+ expect(result.current.formState).toBeDefined();
+ expect(result.current.setServerError).toBeDefined();
+ expect(result.current.persist).toBeDefined();
+ expect(result.current.clearPersisted).toBeDefined();
+ expect(result.current.control).toBeDefined();
+ });
it("does NOT have formAction (that's adapter-specific)", () => {
- const submit = createSuccessSubmit()
- const { result } = renderHook(() => useActionFormCore(submit))
+ const submit = createSuccessSubmit();
+ const { result } = renderHook(() => useActionFormCore(submit));
- expect((result.current as Record).formAction).toBeUndefined()
- })
+ expect((result.current as Record).formAction).toBeUndefined();
+ });
- it('uses provided defaultValues', () => {
- const submit = createSuccessSubmit()
+ it("uses provided defaultValues", () => {
+ const submit = createSuccessSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { email: 'test@example.com' },
+ defaultValues: { email: "test@example.com" },
}),
- )
+ );
- expect(result.current.getValues('email')).toBe('test@example.com')
- })
+ expect(result.current.getValues("email")).toBe("test@example.com");
+ });
// ---- Successful submission ----------------------------------------------
- it('calls the submit function on handleSubmit', async () => {
- const submit = createSuccessSubmit()
+ it("calls the submit function on handleSubmit", async () => {
+ const submit = createSuccessSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { email: 'test@example.com' },
+ defaultValues: { email: "test@example.com" },
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(submit).toHaveBeenCalledWith({ email: 'test@example.com' })
- })
- })
+ expect(submit).toHaveBeenCalledWith({ email: "test@example.com" });
+ });
+ });
- it('sets isSubmitSuccessful after success', async () => {
- const submit = createSuccessSubmit()
+ it("sets isSubmitSuccessful after success", async () => {
+ const submit = createSuccessSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { name: 'test' },
+ defaultValues: { name: "test" },
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- expect(result.current.formState.actionResult).toEqual({ success: true, data: 'ok' })
- })
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ expect(result.current.formState.lastResult).toEqual({ success: true, data: "ok" });
+ });
+ });
// ---- Error handling -----------------------------------------------------
- it('maps server errors to form fields', async () => {
- const submit = createErrorSubmit()
+ it("maps server errors to form fields", async () => {
+ const submit = createErrorSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(result.current.formState.submitErrors).toEqual({
- email: ['Invalid email address'],
- })
- expect(result.current.formState.isSubmitSuccessful).toBe(false)
- })
- })
-
- it('handles thrown errors gracefully', async () => {
- const submit = createThrowingSubmit()
- const onError = vi.fn()
+ expect(result.current.formState.serverErrors).toEqual({
+ email: ["Invalid email address"],
+ });
+ expect(result.current.formState.isSubmitSuccessful).toBe(false);
+ });
+ });
+
+ it("handles thrown errors gracefully", async () => {
+ const submit = createThrowingSubmit();
+ const onError = vi.fn();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
onError,
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(false)
- expect(onError).toHaveBeenCalled()
- })
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(false);
+ expect(onError).toHaveBeenCalled();
+ });
+ });
// ---- Callbacks ----------------------------------------------------------
- it('calls onSuccess callback', async () => {
- const submit = createSuccessSubmit()
- const onSuccess = vi.fn()
+ it("calls onSuccess callback", async () => {
+ const submit = createSuccessSubmit();
+ const onSuccess = vi.fn();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { name: 'test' },
+ defaultValues: { name: "test" },
onSuccess,
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(onSuccess).toHaveBeenCalledWith({ success: true, data: 'ok' })
- })
- })
+ expect(onSuccess).toHaveBeenCalledWith({ success: true, data: "ok" });
+ });
+ });
- it('calls onError callback on field errors', async () => {
- const submit = createErrorSubmit()
- const onError = vi.fn()
+ it("calls onError callback on field errors", async () => {
+ const submit = createErrorSubmit();
+ const onError = vi.fn();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
onError,
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(onError).toHaveBeenCalled()
- })
- })
+ expect(onError).toHaveBeenCalled();
+ });
+ });
// ---- Persistence --------------------------------------------------------
- it('persists values to sessionStorage', async () => {
- const submit = createSuccessSubmit()
+ it("persists values to sessionStorage", async () => {
+ const submit = createSuccessSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { email: '' },
- persistKey: 'test-core-form',
+ defaultValues: { email: "" },
+ persistKey: "test-core-form",
persistDebounce: 0,
}),
- )
+ );
await act(async () => {
- result.current.persist()
- })
+ result.current.persist();
+ });
- const stored = sessionStorage.getItem('test-core-form')
- expect(stored).toBeTruthy()
- })
+ const stored = sessionStorage.getItem("test-core-form");
+ expect(stored).toBeTruthy();
+ });
- it('clears persisted data', async () => {
- sessionStorage.setItem('test-core-clear', JSON.stringify({ email: 'old@test.com' }))
+ it("clears persisted data", async () => {
+ sessionStorage.setItem("test-core-clear", JSON.stringify({ email: "old@test.com" }));
- const submit = createSuccessSubmit()
+ const submit = createSuccessSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- persistKey: 'test-core-clear',
+ persistKey: "test-core-clear",
}),
- )
+ );
await act(async () => {
- result.current.clearPersistedData()
- })
+ result.current.clearPersisted();
+ });
- expect(sessionStorage.getItem('test-core-clear')).toBeNull()
- })
+ expect(sessionStorage.getItem("test-core-clear")).toBeNull();
+ });
- // ---- setSubmitError -----------------------------------------------------
+ // ---- setServerError -----------------------------------------------------
- it('allows manually setting a server error', async () => {
- const submit = createSuccessSubmit()
+ it("allows manually setting a server error", async () => {
+ const submit = createSuccessSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
}),
- )
+ );
act(() => {
- result.current.setSubmitError('email', 'Custom server error')
- })
+ result.current.setServerError("email", "Custom server error");
+ });
await waitFor(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Custom server error')
- expect(emailState.error?.type).toBe('server')
- })
- })
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Custom server error");
+ expect(emailState.error?.type).toBe("server");
+ });
+ });
// ---- DevTools control metadata ------------------------------------------
- it('exposes submission history on control for DevTools', async () => {
- const submit = createSuccessSubmit()
+ it("exposes submission history on control for DevTools", async () => {
+ const submit = createSuccessSubmit();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { name: 'test' },
+ defaultValues: { name: "test" },
}),
- )
+ );
// Initially empty
- expect(result.current.control._submissionHistory).toEqual([])
+ expect(result.current.control._submissionHistory).toEqual([]);
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- const history = result.current.control._submissionHistory
- expect(history).toBeDefined()
- expect(history?.length).toBe(1)
- expect(history?.[0]?.success).toBe(true)
- expect(history?.[0]?.payload).toEqual({ name: 'test' })
- })
- })
+ const history = result.current.control._submissionHistory;
+ expect(history).toBeDefined();
+ expect(history?.length).toBe(1);
+ expect(history?.[0]?.success).toBe(true);
+ expect(history?.[0]?.payload).toEqual({ name: "test" });
+ });
+ });
// ---- Plugin lifecycle ---------------------------------------------------
- it('calls plugin onBeforeSubmit and can prevent submission', async () => {
- const submit = createSuccessSubmit()
- const onBeforeSubmit = vi.fn().mockReturnValue(false)
+ it("calls plugin onBeforeSubmit and can prevent submission", async () => {
+ const submit = createSuccessSubmit();
+ const onBeforeSubmit = vi.fn().mockReturnValue(false);
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { name: 'test' },
- plugins: [{ name: 'blocker', onBeforeSubmit }],
+ defaultValues: { name: "test" },
+ plugins: [{ name: "blocker", onBeforeSubmit }],
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(onBeforeSubmit).toHaveBeenCalled()
- expect(submit).not.toHaveBeenCalled()
- })
- })
+ expect(onBeforeSubmit).toHaveBeenCalled();
+ expect(submit).not.toHaveBeenCalled();
+ });
+ });
- it('calls plugin onSuccess after successful submission', async () => {
- const submit = createSuccessSubmit()
- const pluginOnSuccess = vi.fn()
+ it("calls plugin onSuccess after successful submission", async () => {
+ const submit = createSuccessSubmit();
+ const pluginOnSuccess = vi.fn();
const { result } = renderHook(() =>
useActionFormCore(submit, {
- defaultValues: { name: 'test' },
- plugins: [{ name: 'logger', onSuccess: pluginOnSuccess }],
+ defaultValues: { name: "test" },
+ plugins: [{ name: "logger", onSuccess: pluginOnSuccess }],
}),
- )
+ );
await act(async () => {
- await result.current.handleSubmit()()
- })
+ await result.current.handleSubmit()();
+ });
await waitFor(() => {
- expect(pluginOnSuccess).toHaveBeenCalledWith({ success: true, data: 'ok' }, { name: 'test' })
- })
- })
+ expect(pluginOnSuccess).toHaveBeenCalledWith({ success: true, data: "ok" }, { name: "test" });
+ });
+ });
- it('calls plugin onMount and cleanup', () => {
- const cleanup = vi.fn()
- const onMount = vi.fn().mockReturnValue(cleanup)
- const submit = createSuccessSubmit()
+ it("calls plugin onMount and cleanup", () => {
+ const cleanup = vi.fn();
+ const onMount = vi.fn().mockReturnValue(cleanup);
+ const submit = createSuccessSubmit();
const { unmount } = renderHook(() =>
useActionFormCore(submit, {
- plugins: [{ name: 'mounter', onMount }],
+ plugins: [{ name: "mounter", onMount }],
}),
- )
+ );
- expect(onMount).toHaveBeenCalled()
+ expect(onMount).toHaveBeenCalled();
- unmount()
+ unmount();
- expect(cleanup).toHaveBeenCalled()
- })
-})
+ expect(cleanup).toHaveBeenCalled();
+ });
+});
diff --git a/packages/core/src/__tests__/use-action-form.test.ts b/packages/core/src/__tests__/use-action-form.test.ts
index 0f2a3a1..67ed55a 100644
--- a/packages/core/src/__tests__/use-action-form.test.ts
+++ b/packages/core/src/__tests__/use-action-form.test.ts
@@ -1,7 +1,7 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import type { FormDataServerAction, JsonServerAction } from '../types'
-import { useActionForm } from '../use-action-form'
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { FormDataServerAction, JsonServerAction } from "../types";
+import { useActionForm } from "../use-action-form";
// ---------------------------------------------------------------------------
// Helpers
@@ -9,327 +9,326 @@ import { useActionForm } from '../use-action-form'
/** Classic FormData action (arity 2) */
function createSuccessAction(): FormDataServerAction<{ success: true; data: string }> {
- return vi.fn(async (_prev: unknown, _fd: FormData) => ({ success: true as const, data: 'ok' }))
+ return vi.fn(async (_prev: unknown, _fd: FormData) => ({ success: true as const, data: "ok" }));
}
/** Classic FormData action that returns errors (arity 2) */
function createErrorAction(): FormDataServerAction<{
- errors: { email: string[]; name?: string[] }
+ errors: { email: string[]; name?: string[] };
}> {
return vi.fn(async (_prev: unknown, _fd: FormData) => ({
- errors: { email: ['Invalid email address'], name: ['Name is required'] },
- }))
+ errors: { email: ["Invalid email address"], name: ["Name is required"] },
+ }));
}
/** Classic FormData action that throws (arity 2) */
function createThrowingAction(): FormDataServerAction<{ success: true }> {
return vi.fn(async (_prev: unknown, _fd: FormData) => {
- throw new Error('Network failure')
- })
+ throw new Error("Network failure");
+ });
}
/** JSON action (arity 1) – success */
function createJsonSuccessAction(): JsonServerAction<{ success: true; data: string }> {
- return vi.fn(async (_data: unknown) => ({ success: true as const, data: 'json-ok' }))
+ return vi.fn(async (_data: unknown) => ({ success: true as const, data: "json-ok" }));
}
/** JSON action (arity 1) – returns errors */
function createJsonErrorAction(): JsonServerAction<{
- errors: { email: string[] }
+ errors: { email: string[] };
}> {
return vi.fn(async (_data: unknown) => ({
- errors: { email: ['Invalid email from JSON action'] },
- }))
+ errors: { email: ["Invalid email from JSON action"] },
+ }));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
-describe('useActionForm', () => {
+describe("useActionForm", () => {
beforeEach(() => {
- sessionStorage.clear()
- })
+ sessionStorage.clear();
+ });
// ---- Basic rendering ----------------------------------------------------
- it('returns all expected properties', () => {
- const action = createSuccessAction()
- const { result } = renderHook(() => useActionForm(action))
-
- expect(result.current.register).toBeDefined()
- expect(result.current.handleSubmit).toBeDefined()
- expect(result.current.formState).toBeDefined()
- expect(result.current.setSubmitError).toBeDefined()
- expect(result.current.persist).toBeDefined()
- expect(result.current.clearPersistedData).toBeDefined()
- expect(result.current.formAction).toBeDefined()
- })
-
- it('uses provided defaultValues', () => {
- const action = createSuccessAction()
+ it("returns all expected properties", () => {
+ const action = createSuccessAction();
+ const { result } = renderHook(() => useActionForm(action));
+
+ expect(result.current.register).toBeDefined();
+ expect(result.current.handleSubmit).toBeDefined();
+ expect(result.current.formState).toBeDefined();
+ expect(result.current.setServerError).toBeDefined();
+ expect(result.current.persist).toBeDefined();
+ expect(result.current.clearPersisted).toBeDefined();
+ expect(result.current.formAction).toBeDefined();
+ });
+
+ it("uses provided defaultValues", () => {
+ const action = createSuccessAction();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'test@example.com' },
+ defaultValues: { email: "test@example.com" },
}),
- )
+ );
- expect(result.current.getValues('email')).toBe('test@example.com')
- })
+ expect(result.current.getValues("email")).toBe("test@example.com");
+ });
// ---- Successful submission ----------------------------------------------
- it('submits successfully and updates action state', async () => {
- const action = createSuccessAction()
- const onSuccess = vi.fn()
+ it("submits successfully and updates action state", async () => {
+ const action = createSuccessAction();
+ const onSuccess = vi.fn();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'user@test.com' },
+ defaultValues: { email: "user@test.com" },
onSuccess,
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
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.isSubmitSuccessful).toBe(true);
+ expect(result.current.formState.serverErrors).toBeNull();
+ expect(result.current.formState.lastResult).toEqual({
success: true,
- data: 'ok',
- })
- expect(onSuccess).toHaveBeenCalledWith({ success: true, data: 'ok' })
- })
- })
+ data: "ok",
+ });
+ expect(onSuccess).toHaveBeenCalledWith({ success: true, data: "ok" });
+ });
+ });
// ---- Error mapping ------------------------------------------------------
- it('maps server errors to RHF fields automatically', async () => {
- const action = createErrorAction()
- const onError = vi.fn()
+ it("maps server errors to RHF fields automatically", async () => {
+ const action = createErrorAction();
+ const onError = vi.fn();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '', name: '' },
+ defaultValues: { email: "", name: "" },
onError,
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(false)
- expect(result.current.formState.submitErrors).toEqual({
- email: ['Invalid email address'],
- name: ['Name is required'],
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(false);
+ expect(result.current.formState.serverErrors).toEqual({
+ email: ["Invalid email address"],
+ name: ["Name is required"],
+ });
// Use getFieldState to check RHF errors (avoids proxy subscription issue in tests)
- const emailState = result.current.getFieldState('email')
- const nameState = result.current.getFieldState('name')
- expect(emailState.error?.message).toBe('Invalid email address')
- expect(nameState.error?.message).toBe('Name is required')
- expect(onError).toHaveBeenCalled()
- })
- })
+ const emailState = result.current.getFieldState("email");
+ const nameState = result.current.getFieldState("name");
+ expect(emailState.error?.message).toBe("Invalid email address");
+ expect(nameState.error?.message).toBe("Name is required");
+ expect(onError).toHaveBeenCalled();
+ });
+ });
- // ---- setSubmitError -----------------------------------------------------
+ // ---- setServerError -----------------------------------------------------
- it('allows manually setting a server error', async () => {
- const action = createSuccessAction()
- const { result } = renderHook(() => useActionForm(action, { defaultValues: { email: '' } }))
+ 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(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Already taken')
- expect(emailState.error?.type).toBe('server')
- })
- })
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Already taken");
+ expect(emailState.error?.type).toBe("server");
+ });
+ });
// ---- Throwing action ----------------------------------------------------
- it('handles action that throws an error', async () => {
- const action = createThrowingAction()
- const onError = vi.fn()
+ it("handles action that throws an error", async () => {
+ const action = createThrowingAction();
+ const onError = vi.fn();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
onError,
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(false)
- expect(result.current.formState.isSubmitting).toBe(false)
- expect(onError).toHaveBeenCalledWith(expect.any(Error))
- })
- })
+ expect(result.current.formState.isSubmitSuccessful).toBe(false);
+ expect(result.current.formState.isSubmitting).toBe(false);
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
+ });
+ });
// ---- Custom error mapper ------------------------------------------------
- it('supports custom error mapper', async () => {
- const action: FormDataServerAction<{ validationErrors: { field: string; msg: string }[] }> =
- vi.fn(async (_prev: unknown, _fd: FormData) => ({
- validationErrors: [{ field: 'email', msg: 'Bad email' }],
- }))
+ it("supports custom error mapper", async () => {
+ const action: FormDataServerAction<{ validationErrors: { field: string; msg: string }[] }> = vi.fn(
+ async (_prev: unknown, _fd: FormData) => ({
+ validationErrors: [{ field: "email", msg: "Bad email" }],
+ }),
+ );
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
errorMapper: (res) => {
- if ('validationErrors' in res && Array.isArray(res.validationErrors)) {
- const errors: Record = {}
+ if ("validationErrors" in res && Array.isArray(res.validationErrors)) {
+ const errors: Record = {};
for (const err of res.validationErrors) {
- errors[err.field] = [err.msg]
+ errors[err.field] = [err.msg];
}
- return errors
+ return errors;
}
- return null
+ return null;
},
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Bad email')
- })
- })
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Bad email");
+ });
+ });
// ---- onValid callback ---------------------------------------------------
- it('calls onValid callback before submitting', async () => {
- const action = createSuccessAction()
- const onValid = vi.fn()
+ it("calls onValid callback before submitting", async () => {
+ const action = createSuccessAction();
+ const onValid = vi.fn();
- const { result } = renderHook(() =>
- useActionForm(action, { defaultValues: { email: 'a@b.com' } }),
- )
+ const { result } = renderHook(() => useActionForm(action, { defaultValues: { email: "a@b.com" } }));
await act(async () => {
- const handler = result.current.handleSubmit(onValid)
- await handler()
- })
+ const handler = result.current.handleSubmit(onValid);
+ await handler();
+ });
- expect(onValid).toHaveBeenCalledWith({ email: 'a@b.com' })
- expect(action).toHaveBeenCalled()
- })
+ expect(onValid).toHaveBeenCalledWith({ email: "a@b.com" });
+ expect(action).toHaveBeenCalled();
+ });
// ---- formAction ---------------------------------------------------------
- it('formAction submits directly with FormData', async () => {
- const action = createSuccessAction()
- const { result } = renderHook(() => useActionForm(action))
+ it("formAction submits directly with FormData", async () => {
+ const action = createSuccessAction();
+ const { result } = renderHook(() => useActionForm(action));
- const fd = new FormData()
- fd.append('email', 'direct@test.com')
+ const fd = new FormData();
+ fd.append("email", "direct@test.com");
await act(async () => {
- await result.current.formAction(fd)
- })
+ await result.current.formAction(fd);
+ });
await waitFor(() => {
- expect(action).toHaveBeenCalledWith(null, fd)
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- })
- })
+ expect(action).toHaveBeenCalledWith(null, fd);
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ });
+ });
// =========================================================================
// JSON action tests (arity 1)
// =========================================================================
- describe('JSON actions (arity 1)', () => {
- it('submits successfully with a JSON action', async () => {
- const action = createJsonSuccessAction()
- const onSuccess = vi.fn()
+ describe("JSON actions (arity 1)", () => {
+ it("submits successfully with a JSON action", async () => {
+ const action = createJsonSuccessAction();
+ const onSuccess = vi.fn();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: 'json@test.com' },
+ defaultValues: { email: "json@test.com" },
onSuccess,
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- expect(result.current.formState.actionResult).toEqual({
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ expect(result.current.formState.lastResult).toEqual({
success: true,
- data: 'json-ok',
- })
- expect(onSuccess).toHaveBeenCalledWith({ success: true, data: 'json-ok' })
+ data: "json-ok",
+ });
+ expect(onSuccess).toHaveBeenCalledWith({ success: true, data: "json-ok" });
// The action should have been called with a plain object, NOT FormData
- expect(action).toHaveBeenCalledWith({ email: 'json@test.com' })
- })
- })
+ expect(action).toHaveBeenCalledWith({ email: "json@test.com" });
+ });
+ });
- it('maps errors from a JSON action to RHF fields', async () => {
- const action = createJsonErrorAction()
- const onError = vi.fn()
+ it("maps errors from a JSON action to RHF fields", async () => {
+ const action = createJsonErrorAction();
+ const onError = vi.fn();
const { result } = renderHook(() =>
useActionForm(action, {
- defaultValues: { email: '' },
+ defaultValues: { email: "" },
onError,
}),
- )
+ );
await act(async () => {
- const handler = result.current.handleSubmit()
- await handler()
- })
+ const handler = result.current.handleSubmit();
+ await handler();
+ });
await waitFor(() => {
- expect(result.current.formState.isSubmitSuccessful).toBe(false)
- expect(result.current.formState.submitErrors).toEqual({
- email: ['Invalid email from JSON action'],
- })
- const emailState = result.current.getFieldState('email')
- expect(emailState.error?.message).toBe('Invalid email from JSON action')
- expect(onError).toHaveBeenCalled()
- })
- })
-
- it('formAction converts FormData to object for JSON actions', async () => {
- const action = createJsonSuccessAction()
- const { result } = renderHook(() => useActionForm(action))
-
- const fd = new FormData()
- fd.append('email', 'fd-to-json@test.com')
+ expect(result.current.formState.isSubmitSuccessful).toBe(false);
+ expect(result.current.formState.serverErrors).toEqual({
+ email: ["Invalid email from JSON action"],
+ });
+ const emailState = result.current.getFieldState("email");
+ expect(emailState.error?.message).toBe("Invalid email from JSON action");
+ expect(onError).toHaveBeenCalled();
+ });
+ });
+
+ it("formAction converts FormData to object for JSON actions", async () => {
+ const action = createJsonSuccessAction();
+ const { result } = renderHook(() => useActionForm(action));
+
+ const fd = new FormData();
+ fd.append("email", "fd-to-json@test.com");
await act(async () => {
- await result.current.formAction(fd)
- })
+ await result.current.formAction(fd);
+ });
await waitFor(() => {
// JSON action should receive a plain object, not FormData
- expect(action).toHaveBeenCalledWith({ email: 'fd-to-json@test.com' })
- expect(result.current.formState.isSubmitSuccessful).toBe(true)
- })
- })
- })
-})
+ expect(action).toHaveBeenCalledWith({ email: "fd-to-json@test.com" });
+ expect(result.current.formState.isSubmitSuccessful).toBe(true);
+ });
+ });
+ });
+});
diff --git a/packages/core/src/core-types.ts b/packages/core/src/core-types.ts
index cec9208..8e2a418 100644
--- a/packages/core/src/core-types.ts
+++ b/packages/core/src/core-types.ts
@@ -1,5 +1,5 @@
-import type { DefaultValues, FieldValues, Mode, UseFormReturn } from 'react-hook-form'
-import type { ZodSchema } from 'zod'
+import type { DefaultValues, FieldValues, Mode, UseFormReturn } from "react-hook-form";
+import type { ZodSchema } from "zod";
// ---------------------------------------------------------------------------
// Field-level error shape (Zod `.flatten().fieldErrors` compatible)
@@ -9,7 +9,7 @@ import type { ZodSchema } from 'zod'
* Record mapping field names to arrays of error messages.
* This is the shape produced by `ZodError.flatten().fieldErrors`.
*/
-export type FieldErrorRecord = Record
+export type FieldErrorRecord = Record;
// ---------------------------------------------------------------------------
// Default action result shape
@@ -19,10 +19,10 @@ export type FieldErrorRecord = Record
* Standard result type from an action that uses Zod validation.
*/
export interface ActionResult {
- success?: boolean
- errors?: FieldErrorRecord
- data?: TData
- message?: string
+ success?: boolean;
+ errors?: FieldErrorRecord;
+ data?: TData;
+ message?: string;
}
// ---------------------------------------------------------------------------
@@ -33,7 +33,7 @@ export interface ActionResult {
* A function that takes the raw action result and extracts field errors.
* Return `null` or `undefined` if there are no errors.
*/
-export type ErrorMapper = (result: TResult) => FieldErrorRecord | null | undefined
+export type ErrorMapper = (result: TResult) => FieldErrorRecord | null | undefined;
/**
* Default error mapper that works with the standard Zod flatten format:
@@ -42,14 +42,14 @@ export type ErrorMapper = (result: TResult) => FieldErrorRecord | null
export function defaultErrorMapper(result: TResult): FieldErrorRecord | null | undefined {
if (
result &&
- typeof result === 'object' &&
- 'errors' in result &&
+ typeof result === "object" &&
+ "errors" in result &&
result.errors &&
- typeof result.errors === 'object'
+ typeof result.errors === "object"
) {
- return result.errors as FieldErrorRecord
+ return result.errors as FieldErrorRecord;
}
- return null
+ return null;
}
// ---------------------------------------------------------------------------
@@ -62,7 +62,7 @@ export function defaultErrorMapper(result: TResult): FieldErrorRecord |
* - `'onChange'` – validate on every field change
* - `'onBlur'` – validate when a field loses focus
*/
-export type ClientValidationMode = 'onSubmit' | 'onChange' | 'onBlur'
+export type ClientValidationMode = "onSubmit" | "onChange" | "onBlur";
// ---------------------------------------------------------------------------
// Optimistic UI types
@@ -76,7 +76,7 @@ export type ClientValidationMode = 'onSubmit' | 'onChange' | 'onBlur'
export type OptimisticReducer = (
currentData: TOptimistic,
formValues: TFieldValues,
-) => TOptimistic
+) => TOptimistic;
/**
* The optimistic state object returned by the hook when
@@ -84,11 +84,11 @@ export type OptimisticReducer {
/** The current optimistic data (updated instantly on submit). */
- data: TOptimistic
+ data: TOptimistic;
/** Whether an optimistic update is pending (action in flight). */
- isPending: boolean
+ isPending: boolean;
/** Manually revert to the last confirmed state. */
- rollback: () => void
+ rollback: () => void;
}
// ---------------------------------------------------------------------------
@@ -99,9 +99,7 @@ export interface OptimisticState {
* A generic async submit function. Adapters (Next.js, standalone, etc.)
* are responsible for wrapping their specific action into this shape.
*/
-export type SubmitFunction = (
- data: TFieldValues,
-) => Promise
+export type SubmitFunction = (data: TFieldValues) => Promise;
// ---------------------------------------------------------------------------
// Plugin interface (internal, v3)
@@ -111,20 +109,17 @@ export type SubmitFunction = (
* Internal plugin interface for extending useActionFormCore behavior.
* Not part of the public API yet — used for internal composition.
*/
-export interface ActionFormPlugin<
- TFieldValues extends FieldValues = FieldValues,
- TResult = ActionResult,
-> {
+export interface ActionFormPlugin {
/** Unique name for debugging */
- name: string
+ name: string;
/** Called before submit. Return false to prevent submission. */
- onBeforeSubmit?: (data: TFieldValues) => boolean | Promise
+ onBeforeSubmit?: (data: TFieldValues) => boolean | Promise;
/** Called after a successful submission. */
- onSuccess?: (result: TResult, data: TFieldValues) => void
+ onSuccess?: (result: TResult, data: TFieldValues) => void;
/** Called after a failed submission. */
- onError?: (error: TResult | Error, data: TFieldValues) => void
+ onError?: (error: TResult | Error, data: TFieldValues) => void;
/** Called on mount. Return a cleanup function. */
- onMount?: () => (() => void) | undefined
+ onMount?: () => (() => void) | undefined;
}
// ---------------------------------------------------------------------------
@@ -141,41 +136,41 @@ export interface UseActionFormCoreOptions<
* If `persistKey` is provided and stored data exists, persisted values
* take precedence.
*/
- defaultValues?: DefaultValues
+ defaultValues?: DefaultValues;
/**
* Validation mode passed to React Hook Form.
* @default 'onSubmit'
*/
- mode?: Mode
+ mode?: Mode;
/**
* When provided, enables transparent sessionStorage persistence.
* The form state is saved under this key and restored on mount.
*/
- persistKey?: string
+ persistKey?: string;
/**
* Custom function to extract field errors from the action result.
* By default supports the Zod `.flatten().fieldErrors` format.
*/
- errorMapper?: ErrorMapper
+ errorMapper?: ErrorMapper;
/**
* Callback fired after a successful submission (no field errors returned).
*/
- onSuccess?: (result: TResult) => void
+ onSuccess?: (result: TResult) => void;
/**
* Callback fired when the action throws or returns field errors.
*/
- onError?: (result: TResult | Error) => void
+ onError?: (result: TResult | Error) => void;
/**
* Debounce interval (ms) for sessionStorage persistence.
* @default 300
*/
- persistDebounce?: number
+ persistDebounce?: number;
// ---- Client-side Zod validation -----------------------------------------
@@ -183,35 +178,49 @@ export interface UseActionFormCoreOptions<
* Zod schema for client-side validation.
* If provided, fields are validated in real-time (based on `validationMode`).
*/
- schema?: ZodSchema
+ schema?: ZodSchema;
/**
* Controls when client-side Zod schema validation runs.
* Only takes effect when `schema` is provided.
* @default 'onSubmit'
*/
- validationMode?: ClientValidationMode
+ 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 optimistic data from current data + submitted values.
+ */
+ optimisticData?: OptimisticReducer;
+
+ /**
+ * Initial (confirmed) data used by optimistic mode.
*/
- optimisticKey?: string
+ optimisticInitial?: TOptimistic;
/**
- * Reducer that computes the optimistic state from the current data and
- * the form values being submitted.
- * Required when `optimisticKey` is set.
+ * Alias for `optimisticData`.
+ * @deprecated Use `optimisticData` instead.
*/
- optimisticData?: OptimisticReducer
+ optimisticReducer?: OptimisticReducer;
/**
- * Initial data for the optimistic state.
- * This is the "confirmed" state before any optimistic updates.
+ * Alias for `optimisticInitial`.
+ * @deprecated Use `optimisticInitial` instead.
*/
- optimisticInitial?: TOptimistic
+ optimisticDefault?: TOptimistic;
// ---- Internal plugins (v3) ----------------------------------------------
@@ -219,7 +228,7 @@ export interface UseActionFormCoreOptions<
* Internal plugin array. Not part of the public API yet.
* @internal
*/
- plugins?: ActionFormPlugin[]
+ plugins?: ActionFormPlugin[];
}
// ---------------------------------------------------------------------------
@@ -228,17 +237,27 @@ export interface UseActionFormCoreOptions<
export interface ActionFormState {
/** Whether the form is currently being submitted. */
- isSubmitting: boolean
+ isSubmitting: boolean;
/** Whether the last submission was successful (no field errors). */
- isSubmitSuccessful: boolean
+ isSubmitSuccessful: boolean;
/** Raw error record returned by the action (via errorMapper). */
- submitErrors: FieldErrorRecord | null
- /** The full result from the last action invocation, if any. */
- actionResult: TResult | null
+ submitErrors: FieldErrorRecord | null;
+ /** 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.
*/
- isPending: boolean
+ isPending: boolean;
}
// ---------------------------------------------------------------------------
@@ -249,7 +268,7 @@ export interface UseActionFormCoreReturn<
TFieldValues extends FieldValues = FieldValues,
TResult = ActionResult,
TOptimistic = undefined,
-> extends Omit, 'handleSubmit'> {
+> extends Omit, "handleSubmit"> {
/**
* Enhanced handleSubmit that submits via the provided submit function.
* Call with no arguments: `onSubmit={handleSubmit()}`.
@@ -257,46 +276,58 @@ export interface UseActionFormCoreReturn<
*/
handleSubmit: (
onValid?: (data: TFieldValues) => void | Promise,
- ) => (e?: React.BaseSyntheticEvent) => Promise
+ ) => (e?: React.BaseSyntheticEvent) => Promise;
/**
* Extended form state including action status.
*/
- formState: UseFormReturn['formState'] & ActionFormState
+ formState: UseFormReturn["formState"] & ActionFormState;
/**
* Manually set a server-side error on a specific field.
*/
- setSubmitError: (field: keyof TFieldValues & string, message: string) => void
+ 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.
*/
- persist: () => void
+ persist: () => void;
/**
* Clear persisted data from sessionStorage.
* Only works when `persistKey` is set.
*/
- clearPersistedData: () => void
+ clearPersistedData: () => void;
+
+ /**
+ * Alias for `clearPersistedData`.
+ * @deprecated Use `clearPersistedData` instead.
+ */
+ clearPersisted: () => void;
/**
* Optimistic state. Only populated when `optimisticKey` is provided.
* Contains `data`, `isPending`, and `rollback()`.
*/
- optimistic: TOptimistic extends undefined ? undefined : OptimisticState
+ optimistic: TOptimistic extends undefined ? undefined : OptimisticState;
/**
* A control object that exposes internals for DevTools.
* @internal
*/
- control: UseFormReturn['control'] & {
+ control: UseFormReturn["control"] & {
/** Submission history for DevTools inspection. */
- _submissionHistory?: SubmissionRecord[]
+ _submissionHistory?: SubmissionRecord[];
/** The core action form state. */
- _actionFormState?: ActionFormState
- }
+ _actionFormState?: ActionFormState;
+ };
}
// ---------------------------------------------------------------------------
@@ -305,17 +336,17 @@ export interface UseActionFormCoreReturn<
export interface SubmissionRecord {
/** Unique ID for this submission */
- id: string
+ id: string;
/** Timestamp of submission */
- timestamp: number
+ timestamp: number;
/** The payload sent */
- payload: Record
+ payload: Record;
/** The response from the action */
- response: TResult | null
+ response: TResult | null;
/** Error, if any */
- error: Error | null
+ error: Error | null;
/** Duration in ms */
- duration: number
+ duration: number;
/** Whether the submission was successful */
- success: boolean
+ success: boolean;
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index b502173..19668d4 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,5 +1,5 @@
-import type { DefaultValues, FieldValues, Mode, UseFormReturn } from 'react-hook-form'
-import type { ZodSchema } from 'zod'
+import type { DefaultValues, FieldValues, Mode, UseFormReturn } from "react-hook-form";
+import type { ZodSchema } from "zod";
// ---------------------------------------------------------------------------
// Server Action type – supports both Next.js App Router conventions:
@@ -10,7 +10,7 @@ import type { ZodSchema } from 'zod'
/**
* A Server Action that receives a single JSON payload and returns a promise.
*/
-export type JsonServerAction = (data: unknown) => Promise
+export type JsonServerAction = (data: unknown) => Promise;
/**
* A Server Action that receives the previous state and a FormData.
@@ -19,28 +19,23 @@ export type JsonServerAction = (data: unknown) => Promise = (
prevState: Awaited | null,
formData: FormData,
-) => Promise
+) => Promise;
/**
* Represents a Next.js Server Action function signature.
* Supports both the classic (prevState, formData) signature and the
* simpler (data) => Promise signature for JSON-based actions.
*/
-export type ServerAction =
- | JsonServerAction
- | FormDataServerAction
+export type ServerAction = JsonServerAction | FormDataServerAction;
/**
* A Server Action created with `withZod` that has the schema attached.
* This allows `useActionForm` to automatically infer the schema for
* client-side validation.
*/
-export type ZodServerAction<
- TSchema extends ZodSchema = ZodSchema,
- TResult = unknown,
-> = ServerAction & {
- __schema: TSchema
-}
+export type ZodServerAction = ServerAction & {
+ __schema: TSchema;
+};
// ---------------------------------------------------------------------------
// Infer the result type of a Server Action
@@ -52,8 +47,8 @@ export type ZodServerAction<
export type InferActionResult = TAction extends JsonServerAction
? Awaited
: TAction extends FormDataServerAction
- ? Awaited
- : never
+ ? Awaited
+ : never;
// ---------------------------------------------------------------------------
// Standard field-level error shape returned by Zod `.flatten().fieldErrors`
@@ -63,7 +58,7 @@ export type InferActionResult = TAction extends JsonServerAction
+export type FieldErrorRecord = Record;
// ---------------------------------------------------------------------------
// Default action result shape (what most Server Actions return)
@@ -73,10 +68,10 @@ export type FieldErrorRecord = Record
* Standard result type from a Server Action that uses Zod validation.
*/
export interface ActionResult {
- success?: boolean
- errors?: FieldErrorRecord
- data?: TData
- message?: string
+ success?: boolean;
+ errors?: FieldErrorRecord;
+ data?: TData;
+ message?: string;
}
// ---------------------------------------------------------------------------
@@ -87,16 +82,14 @@ export interface ActionResult {
* A function that takes the raw action result and extracts field errors.
* Return `null` or `undefined` if there are no errors.
*/
-export type ErrorMapper = (result: TResult) => FieldErrorRecord | null | undefined
+export type ErrorMapper = (result: TResult) => FieldErrorRecord | null | undefined;
/**
* Detect whether a Server Action uses the FormData signature (arity === 2)
* or the JSON signature (arity <= 1).
*/
-export function isFormDataAction(
- action: ServerAction,
-): action is FormDataServerAction {
- return action.length >= 2
+export function isFormDataAction(action: ServerAction): action is FormDataServerAction {
+ return action.length >= 2;
}
/**
@@ -106,14 +99,14 @@ export function isFormDataAction(
export function defaultErrorMapper(result: TResult): FieldErrorRecord | null | undefined {
if (
result &&
- typeof result === 'object' &&
- 'errors' in result &&
+ typeof result === "object" &&
+ "errors" in result &&
result.errors &&
- typeof result.errors === 'object'
+ typeof result.errors === "object"
) {
- return result.errors as FieldErrorRecord
+ return result.errors as FieldErrorRecord;
}
- return null
+ return null;
}
/**
@@ -123,7 +116,7 @@ export function defaultErrorMapper(result: TResult): FieldErrorRecord |
export function hasAttachedSchema(
action: ServerAction,
): action is ZodServerAction {
- return '__schema' in action && (action as unknown as Record).__schema != null
+ return "__schema" in action && (action as unknown as Record).__schema != null;
}
// ---------------------------------------------------------------------------
@@ -136,7 +129,7 @@ export function hasAttachedSchema(
* - `'onChange'` – validate on every field change
* - `'onBlur'` – validate when a field loses focus
*/
-export type ClientValidationMode = 'onSubmit' | 'onChange' | 'onBlur'
+export type ClientValidationMode = "onSubmit" | "onChange" | "onBlur";
// ---------------------------------------------------------------------------
// Optimistic UI types
@@ -150,7 +143,7 @@ export type ClientValidationMode = 'onSubmit' | 'onChange' | 'onBlur'
export type OptimisticReducer = (
currentData: TOptimistic,
formValues: TFieldValues,
-) => TOptimistic
+) => TOptimistic;
/**
* The optimistic state object returned by `useActionForm` when
@@ -158,11 +151,11 @@ export type OptimisticReducer {
/** The current optimistic data (updated instantly on submit). */
- data: TOptimistic
+ data: TOptimistic;
/** Whether an optimistic update is pending (action in flight). */
- isPending: boolean
+ isPending: boolean;
/** Manually revert to the last confirmed state. */
- rollback: () => void
+ rollback: () => void;
}
// ---------------------------------------------------------------------------
@@ -179,41 +172,41 @@ export interface UseActionFormOptions<
* If `persistKey` is provided and stored data exists, persisted values
* take precedence.
*/
- defaultValues?: DefaultValues
+ defaultValues?: DefaultValues;
/**
* Validation mode passed to React Hook Form.
* @default 'onSubmit'
*/
- mode?: Mode
+ mode?: Mode;
/**
* When provided, enables transparent sessionStorage persistence.
* The form state is saved under this key and restored on mount.
*/
- persistKey?: string
+ persistKey?: string;
/**
* Custom function to extract field errors from the action result.
* By default supports the Zod `.flatten().fieldErrors` format.
*/
- errorMapper?: ErrorMapper
+ errorMapper?: ErrorMapper;
/**
* Callback fired after a successful submission (no field errors returned).
*/
- onSuccess?: (result: TResult) => void
+ onSuccess?: (result: TResult) => void;
/**
* Callback fired when the action throws or returns field errors.
*/
- onError?: (result: TResult | Error) => void
+ onError?: (result: TResult | Error) => void;
/**
* Debounce interval (ms) for sessionStorage persistence.
* @default 300
*/
- persistDebounce?: number
+ persistDebounce?: number;
// ---- v2: Client-side Zod validation -------------------------------------
@@ -222,36 +215,49 @@ export interface UseActionFormOptions<
* If provided, fields are validated in real-time (based on `validationMode`).
* If the action was created with `withZod`, the schema is auto-detected.
*/
- schema?: ZodSchema
+ schema?: ZodSchema;
/**
* Controls when client-side Zod schema validation runs.
* Only takes effect when `schema` is provided (or inferred from `withZod`).
* @default 'onSubmit'
*/
- validationMode?: ClientValidationMode
+ 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 optimistic data from current data + submitted values.
+ */
+ optimisticData?: OptimisticReducer;
+
+ /**
+ * Initial (confirmed) data used by optimistic mode.
*/
- optimisticKey?: string
+ optimisticInitial?: TOptimistic;
/**
- * Reducer that computes the optimistic state from the current data and
- * the form values being submitted.
- * Required when `optimisticKey` is set.
+ * Alias for `optimisticData`.
+ * @deprecated Use `optimisticData` instead.
*/
- optimisticData?: OptimisticReducer
+ optimisticReducer?: OptimisticReducer;
/**
- * Initial data for the optimistic state.
- * This is the "confirmed" state before any optimistic updates.
+ * Alias for `optimisticInitial`.
+ * @deprecated Use `optimisticInitial` instead.
*/
- optimisticInitial?: TOptimistic
+ optimisticDefault?: TOptimistic;
}
// ---------------------------------------------------------------------------
@@ -260,18 +266,28 @@ export interface UseActionFormOptions<
export interface ActionFormState {
/** Whether the form is currently being submitted to the server action. */
- isSubmitting: boolean
+ isSubmitting: boolean;
/** Whether the last submission was successful (no field errors). */
- isSubmitSuccessful: boolean
+ 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. */
- actionResult: TResult | null
+ submitErrors: FieldErrorRecord | null;
+ /** 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.
*/
- isPending: boolean
+ isPending: boolean;
}
// ---------------------------------------------------------------------------
@@ -282,7 +298,7 @@ export interface UseActionFormReturn<
TFieldValues extends FieldValues = FieldValues,
TResult = ActionResult,
TOptimistic = undefined,
-> extends Omit, 'handleSubmit'> {
+> extends Omit, "handleSubmit"> {
/**
* Enhanced handleSubmit that submits to the Server Action.
* Call with no arguments: `onSubmit={handleSubmit()}`.
@@ -290,38 +306,50 @@ export interface UseActionFormReturn<
*/
handleSubmit: (
onValid?: (data: TFieldValues) => void | Promise,
- ) => (e?: React.BaseSyntheticEvent) => Promise
+ ) => (e?: React.BaseSyntheticEvent) => Promise;
/**
* Extended form state including server action status.
*/
- formState: UseFormReturn['formState'] & ActionFormState
+ formState: UseFormReturn["formState"] & ActionFormState;
/**
* Manually set a server-side error on a specific field.
*/
- setSubmitError: (field: keyof TFieldValues & string, message: string) => void
+ 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.
*/
- persist: () => void
+ persist: () => void;
/**
* Clear persisted data from sessionStorage.
* Only works when `persistKey` is set.
*/
- clearPersistedData: () => void
+ clearPersistedData: () => void;
+
+ /**
+ * Alias for `clearPersistedData`.
+ * @deprecated Use `clearPersistedData` instead.
+ */
+ clearPersisted: () => void;
/**
* The underlying form action compatible with Next.js ``.
*/
- formAction: (formData: FormData) => Promise
+ formAction: (formData: FormData) => Promise;
/**
* Optimistic state (v2). Only populated when `optimisticKey` is provided.
* Contains `data`, `isPending`, and `rollback()`.
*/
- optimistic: TOptimistic extends undefined ? undefined : OptimisticState
+ optimistic: TOptimistic extends undefined ? undefined : OptimisticState;
}
diff --git a/packages/core/src/use-action-form-core.ts b/packages/core/src/use-action-form-core.ts
index 573f2c4..53666f5 100644
--- a/packages/core/src/use-action-form-core.ts
+++ b/packages/core/src/use-action-form-core.ts
@@ -1,8 +1,8 @@
-'use client'
+"use client";
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { type DefaultValues, type FieldPath, type FieldValues, useForm } from 'react-hook-form'
-import type { ZodError, ZodSchema } from 'zod'
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { type DefaultValues, type FieldPath, type FieldValues, useForm } from "react-hook-form";
+import type { ZodError, ZodSchema } from "zod";
import type {
ActionFormState,
@@ -14,37 +14,30 @@ import type {
SubmitFunction,
UseActionFormCoreOptions,
UseActionFormCoreReturn,
-} from './core-types'
-import { defaultErrorMapper } from './core-types'
-import { clearPersistedValues, debounce, loadPersistedValues, savePersistedValues } from './persist'
-import {
- hasUseOptimistic,
- useOptimistic as useOptimisticReact19,
- useTransition,
-} from './react-shim'
+} from "./core-types";
+import { defaultErrorMapper } from "./core-types";
+import { clearPersistedValues, debounce, loadPersistedValues, savePersistedValues } from "./persist";
+import { hasUseOptimistic, useOptimistic as useOptimisticReact19, useTransition } from "./react-shim";
// ---------------------------------------------------------------------------
// Internal: client-side Zod validation helper
// ---------------------------------------------------------------------------
-function validateWithSchema(
- schema: ZodSchema,
- values: Record,
-): FieldErrorRecord | null {
- const result = schema.safeParse(values)
- if (result.success) return null
+function validateWithSchema(schema: ZodSchema, values: Record): FieldErrorRecord | null {
+ const result = schema.safeParse(values);
+ if (result.success) return null;
- const zodError = result.error as ZodError
- const flat = zodError.flatten()
- const errors: FieldErrorRecord = {}
+ const zodError = result.error as ZodError;
+ const flat = zodError.flatten();
+ const errors: FieldErrorRecord = {};
for (const [field, messages] of Object.entries(flat.fieldErrors)) {
if (messages && messages.length > 0) {
- errors[field] = messages as string[]
+ errors[field] = messages as string[];
}
}
- return Object.keys(errors).length > 0 ? errors : null
+ return Object.keys(errors).length > 0 ? errors : null;
}
// ---------------------------------------------------------------------------
@@ -52,8 +45,8 @@ function validateWithSchema(
// ---------------------------------------------------------------------------
function useOptimisticFallback(initial: T): [T, (value: T) => void] {
- const [state, setState] = useState(initial)
- return [state, setState]
+ const [state, setState] = useState(initial);
+ return [state, setState];
}
// ---------------------------------------------------------------------------
@@ -86,50 +79,57 @@ export function useActionFormCore<
): UseActionFormCoreReturn {
const {
defaultValues: optionDefaults,
- mode = 'onSubmit',
+ mode = "onSubmit",
persistKey,
errorMapper = defaultErrorMapper as ErrorMapper,
onSuccess,
onError,
persistDebounce = 300,
schema: optionsSchema,
- validationMode = 'onSubmit',
+ validationMode,
+ clientValidation,
optimisticKey,
optimisticData,
optimisticInitial,
+ optimisticReducer,
+ optimisticDefault,
plugins = [],
- } = options
+ } = 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])
+ const resolvedSchema = useMemo(() => optionsSchema ?? undefined, [optionsSchema]);
// ----- Resolve initial values (persisted > options) ----------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally run once on mount
const resolvedDefaults = useMemo | undefined>(() => {
if (persistKey) {
- const persisted = loadPersistedValues(persistKey)
+ const persisted = loadPersistedValues(persistKey);
if (persisted) {
return {
...(optionDefaults as Record | undefined),
...persisted,
- } as DefaultValues
+ } as DefaultValues;
}
}
- return optionDefaults
- }, [])
+ return optionDefaults;
+ }, []);
// ----- React Hook Form ---------------------------------------------------
const form = useForm({
defaultValues: resolvedDefaults,
mode,
- })
+ });
// ----- useTransition (React 18 & 19) ------------------------------------
- const [isTransitioning, startTransition] = useTransition()
+ const [isTransitioning, startTransition] = useTransition();
// ----- Action state ------------------------------------------------------
@@ -138,112 +138,117 @@ export function useActionFormCore<
isSubmitSuccessful: false,
submitErrors: null,
actionResult: null,
+ serverErrors: null,
+ lastResult: null,
isPending: false,
- })
+ });
// ----- Submission history (for DevTools) ----------------------------------
- const submissionHistoryRef = useRef[]>([])
+ const submissionHistoryRef = useRef[]>([]);
// ----- 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 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)
- }, [setOptimistic])
+ setOptimistic(confirmedOptimisticRef.current);
+ }, [setOptimistic]);
// ----- Persistence -------------------------------------------------------
const debouncedSave = useMemo(() => {
- if (!persistKey) return null
+ if (!persistKey) return null;
return debounce((values: TFieldValues) => {
- savePersistedValues(persistKey, values)
- }, persistDebounce)
- }, [persistKey, persistDebounce])
+ savePersistedValues(persistKey, values);
+ }, persistDebounce);
+ }, [persistKey, persistDebounce]);
useEffect(() => {
- if (!persistKey || !debouncedSave) return
+ if (!persistKey || !debouncedSave) return;
const subscription = form.watch((values) => {
- debouncedSave(values as TFieldValues)
- })
+ debouncedSave(values as TFieldValues);
+ });
- return () => subscription.unsubscribe()
- }, [persistKey, debouncedSave, form])
+ return () => subscription.unsubscribe();
+ }, [persistKey, debouncedSave, form]);
// ----- 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 (!name) return;
- if (validationMode === 'onChange' || type === 'blur') {
- const fieldResult = resolvedSchema.safeParse(values)
+ if (resolvedValidationMode === "onChange" || type === "blur") {
+ const fieldResult = resolvedSchema.safeParse(values);
if (fieldResult.success) {
- form.clearErrors(name as FieldPath)
+ form.clearErrors(name as FieldPath);
} else {
- const zodError = fieldResult.error as ZodError
- const flat = zodError.flatten()
- const fieldErrors = flat.fieldErrors[name]
+ const zodError = fieldResult.error as ZodError;
+ const flat = zodError.flatten();
+ const fieldErrors = flat.fieldErrors[name];
if (fieldErrors && fieldErrors.length > 0) {
form.setError(name as FieldPath, {
- type: 'validation',
+ type: "validation",
message: fieldErrors[0],
- })
+ });
} else {
- form.clearErrors(name as FieldPath)
+ form.clearErrors(name as FieldPath);
}
}
}
- })
+ });
- return () => subscription.unsubscribe()
- }, [resolvedSchema, validationMode, form])
+ return () => subscription.unsubscribe();
+ }, [resolvedSchema, resolvedValidationMode, form]);
// ----- Plugin lifecycle: onMount -----------------------------------------
// biome-ignore lint/correctness/useExhaustiveDependencies: plugins identity changes every render; run once on mount
useEffect(() => {
- const cleanups = plugins.map((p) => p.onMount?.()).filter(Boolean) as (() => void)[]
+ const cleanups = plugins.map((p) => p.onMount?.()).filter(Boolean) as (() => void)[];
return () => {
for (const cleanup of cleanups) {
- cleanup()
+ cleanup();
}
- }
- }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
+ };
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
// ----- Manual persist / clear --------------------------------------------
const persist = useCallback(() => {
- if (!persistKey) return
- savePersistedValues(persistKey, form.getValues())
- }, [persistKey, form])
+ if (!persistKey) return;
+ savePersistedValues(persistKey, form.getValues());
+ }, [persistKey, form]);
- const clearPersisted = useCallback(() => {
- if (!persistKey) return
- clearPersistedValues(persistKey)
- }, [persistKey])
+ const clearPersistedData = useCallback(() => {
+ if (!persistKey) return;
+ clearPersistedValues(persistKey);
+ }, [persistKey]);
+
+ const clearPersisted = clearPersistedData;
// ----- Set a server error on a field -------------------------------------
const setSubmitError = useCallback(
(field: keyof TFieldValues & string, message: string) => {
- form.setError(field as never, { type: 'server', message })
+ form.setError(field as never, { type: "server", message });
},
[form],
- )
+ );
+
+ const setServerError = setSubmitError;
// ----- Map server errors to RHF ------------------------------------------
@@ -252,40 +257,42 @@ export function useActionFormCore<
for (const [field, messages] of Object.entries(errors)) {
if (messages && messages.length > 0) {
form.setError(field as never, {
- type: 'server',
+ type: "server",
message: messages[0],
- })
+ });
}
}
},
[form],
- )
+ );
// ----- Core submit logic -------------------------------------------------
const executeSubmit = useCallback(
async (data: TFieldValues) => {
// Client-side schema validation (for onSubmit mode)
- if (resolvedSchema && validationMode === 'onSubmit') {
- const clientErrors = validateWithSchema(resolvedSchema, data as Record)
+ if (resolvedSchema && resolvedValidationMode === "onSubmit") {
+ const clientErrors = validateWithSchema(resolvedSchema, data as Record);
if (clientErrors) {
- applyServerErrors(clientErrors)
+ applyServerErrors(clientErrors);
setActionState({
isSubmitting: false,
isSubmitSuccessful: false,
submitErrors: clientErrors,
actionResult: null,
+ serverErrors: clientErrors,
+ lastResult: null,
isPending: false,
- })
- return
+ });
+ return;
}
}
// Plugin: onBeforeSubmit
for (const plugin of plugins) {
if (plugin.onBeforeSubmit) {
- const shouldContinue = await plugin.onBeforeSubmit(data)
- if (shouldContinue === false) return
+ const shouldContinue = await plugin.onBeforeSubmit(data);
+ if (shouldContinue === false) return;
}
}
@@ -294,20 +301,21 @@ 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)
- setOptimistic(optimisticResult)
+ if (hasOptimistic && resolvedOptimisticData) {
+ const optimisticResult = resolvedOptimisticData(confirmedOptimisticRef.current, data);
+ setOptimistic(optimisticResult);
}
- const startTime = Date.now()
+ const startTime = Date.now();
try {
- const result = await submit(data)
+ const result = await submit(data);
- const fieldErrors = errorMapper(result)
+ const fieldErrors = errorMapper(result);
// Record submission for DevTools
const record: SubmissionRecord = {
@@ -318,14 +326,14 @@ export function useActionFormCore<
error: null,
duration: Date.now() - startTime,
success: !fieldErrors || Object.keys(fieldErrors).length === 0,
- }
- submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record]
+ };
+ submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record];
if (fieldErrors && Object.keys(fieldErrors).length > 0) {
- applyServerErrors(fieldErrors)
+ applyServerErrors(fieldErrors);
if (hasOptimistic) {
- rollbackOptimistic()
+ rollbackOptimistic();
}
setActionState({
@@ -333,38 +341,42 @@ export function useActionFormCore<
isSubmitSuccessful: false,
submitErrors: fieldErrors,
actionResult: result,
+ serverErrors: fieldErrors,
+ lastResult: result,
isPending: false,
- })
+ });
// Plugin: onError
for (const plugin of plugins) {
- plugin.onError?.(result, data)
+ plugin.onError?.(result, data);
}
- onError?.(result)
+ 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
- if (persistKey) clearPersistedValues(persistKey)
+ if (persistKey) clearPersistedValues(persistKey);
setActionState({
isSubmitting: false,
isSubmitSuccessful: true,
submitErrors: null,
actionResult: result,
+ serverErrors: null,
+ lastResult: result,
isPending: false,
- })
+ });
// Plugin: onSuccess
for (const plugin of plugins) {
- plugin.onSuccess?.(result, data)
+ plugin.onSuccess?.(result, data);
}
- onSuccess?.(result)
+ onSuccess?.(result);
}
} catch (error) {
// Record failed submission for DevTools
@@ -376,11 +388,11 @@ export function useActionFormCore<
error: error instanceof Error ? error : new Error(String(error)),
duration: Date.now() - startTime,
success: false,
- }
- submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record]
+ };
+ submissionHistoryRef.current = [...submissionHistoryRef.current.slice(-49), record];
if (hasOptimistic) {
- rollbackOptimistic()
+ rollbackOptimistic();
}
setActionState((prev) => ({
@@ -388,16 +400,16 @@ export function useActionFormCore<
isSubmitting: false,
isSubmitSuccessful: false,
isPending: false,
- }))
+ }));
- const wrappedError = error instanceof Error ? error : new Error(String(error))
+ const wrappedError = error instanceof Error ? error : new Error(String(error));
// Plugin: onError
for (const plugin of plugins) {
- plugin.onError?.(wrappedError as TResult & Error, data)
+ plugin.onError?.(wrappedError as TResult & Error, data);
}
- onError?.(wrappedError)
+ onError?.(wrappedError);
}
},
[
@@ -408,30 +420,30 @@ export function useActionFormCore<
onError,
persistKey,
resolvedSchema,
- validationMode,
+ resolvedValidationMode,
hasOptimistic,
- optimisticData,
+ resolvedOptimisticData,
setOptimistic,
rollbackOptimistic,
plugins,
],
- )
+ );
// ----- handleSubmit wrapper ----------------------------------------------
const handleSubmit = useCallback(
(onValid?: (data: TFieldValues) => void | Promise) => {
return form.handleSubmit(async (data) => {
- if (onValid) await onValid(data)
+ if (onValid) await onValid(data);
// @ts-ignore – React 19 supports async transitions; React 18 ignores the promise
startTransition(async () => {
- await executeSubmit(data)
- })
- })
+ await executeSubmit(data);
+ });
+ });
},
[form, executeSubmit, startTransition],
- )
+ );
// ----- Compose return value ----------------------------------------------
@@ -452,30 +464,30 @@ export function useActionFormCore<
isPending: isTransitioning || actionState.isPending,
}),
[form.formState, actionState, isTransitioning],
- )
+ );
// ----- Compose optimistic return -----------------------------------------
const optimisticReturn = useMemo(() => {
- if (!hasOptimistic) return undefined
+ if (!hasOptimistic) return undefined;
return {
data: optimisticState,
isPending: isTransitioning || actionState.isPending,
rollback: rollbackOptimistic,
- } as OptimisticState
- }, [hasOptimistic, optimisticState, isTransitioning, actionState.isPending, rollbackOptimistic])
+ } as OptimisticState;
+ }, [hasOptimistic, optimisticState, isTransitioning, actionState.isPending, rollbackOptimistic]);
// ----- Compose control with DevTools metadata ----------------------------
const enhancedControl = useMemo(() => {
const ctrl = form.control as typeof form.control & {
- _submissionHistory: SubmissionRecord[]
- _actionFormState: ActionFormState
- }
- ctrl._submissionHistory = submissionHistoryRef.current
- ctrl._actionFormState = actionState
- return ctrl
- }, [form.control, actionState])
+ _submissionHistory: SubmissionRecord[];
+ _actionFormState: ActionFormState;
+ };
+ ctrl._submissionHistory = submissionHistoryRef.current;
+ ctrl._actionFormState = actionState;
+ return ctrl;
+ }, [form.control, actionState]);
return {
...form,
@@ -483,8 +495,10 @@ export function useActionFormCore<
handleSubmit,
formState: composedFormState,
setSubmitError,
+ setServerError,
persist,
- clearPersistedData: clearPersisted,
+ clearPersistedData,
+ clearPersisted,
optimistic: optimisticReturn,
- } as UseActionFormCoreReturn
+ } as UseActionFormCoreReturn