Tier 1 · Must-haveRecipe #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

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

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 <p className="text-green-400">Login successful! Redirecting...</p>
  }

  return (
    <form onSubmit={handleSubmit()}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p className="text-red-400">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p className="text-red-400">{errors.password.message}</p>}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? 'Signing in…' : 'Sign In'}
      </button>
    </form>
  )
}
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

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: <form onSubmit={handleSubmit()}>. 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

  • 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