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.
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 |
Workflow comparison
Manual — steps per form
- Define Zod schema in a shared module
- Pass
zodResolver(schema)touseForm - In the Server Action: call
schema.safeParse(data), formatfieldErrors, return them - In the component: inspect action result, loop over errors, call
setError(field, …)for each - Wire
useTransitionaround the action call to getisPending - If you need optimistic UI: add
useOptimistic, compute next state, pass tostartTransition, handle rollback - If you need persistence: write a
useEffectto save/restore fromsessionStorage, debounce it, clear on unmount
Each new form repeats steps 3–7 from scratch.
hookform-action — steps per form
- Wrap server action with
withZod(schema, handler) - Call
useActionForm(action, options)in the component - Use
register,handleSubmit(), andformState.errorsas you normally would with RHF
Steps 4–7 from the manual list become opt-in options: validationMode, optimisticData, persistKey.
Code comparison — Login form
Same feature set: server validation, automatic error display, pending state.
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 }
}
)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<typeof schema>
export function LoginForm() {
const [isPending, startTransition] = useTransition()
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<Fields>({
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 (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isPending || isSubmitting}>
{isPending ? 'Signing in…' : 'Sign In'}
</button>
</form>
)
}hookform-action
// LoginForm.tsx
'use client'
import { useActionForm } from 'hookform-action'
import { loginAction } from './actions'
// No resolver — schema is auto-detected
// from the withZod wrapper on the action
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isPending },
} = useActionForm(loginAction, {
defaultValues: { email: '', password: '' },
// enable real-time validation with
// the same schema — no extra imports
validationMode: 'onChange',
})
return (
<form onSubmit={handleSubmit()}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isPending}>
{isPending ? 'Signing in…' : 'Sign In'}
</button>
</form>
)
}~55
lines — manual client component
~25
lines — with hookform-action
−3
concepts to keep in sync (resolver, transition, setError loop)
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.