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.

ConcernManualhookform-action
Server-side Zod validationWrite schema.safeParse() + format errors in every actionwithZod(schema, handler) — one wrapper, done
Error mapping to RHF fieldsCall setError() for each field after parsing the action resultAutomatic via defaultErrorMapper — override with errorMapper when needed
Client-side validationPass resolver to useForm and keep schema in sync with serverSet validationMode — schema auto-detected from withZod
Pending / loading stateuseTransition + useState wiring around startTransitionformState.isPending backed by useTransition internally
Optimistic UIuseOptimistic + useTransition + manual rollback logicoptimisticData option — hook handles pending state and rollback
Multi-step persistenceCustom useEffect + sessionStorage reads/writes per steppersistKey option — debounced, SSR-safe, clears on success
FormData supportDetect action arity and convert FormData to object manuallyDetected automatically via action arity — withZod handles conversion
Success / error callbacksInline .then()/.catch() chains after calling the actiononSuccess / onError options
Debug toolingRHF DevTools (state only)hookform-action-devtools — full submission history + form state panel

Workflow comparison

Manual — steps per form

  1. Define Zod schema in a shared module
  2. Pass zodResolver(schema) to useForm
  3. In the Server Action: call schema.safeParse(data), format fieldErrors, return them
  4. In the component: inspect action result, loop over errors, call setError(field, …) for each
  5. Wire useTransition around the action call to get isPending
  6. If you need optimistic UI: add useOptimistic, compute next state, pass to startTransition, handle rollback
  7. 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 — steps per form

  1. Wrap server action with withZod(schema, handler)
  2. Call useActionForm(action, options) in the component
  3. 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.

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.