Login Form
The foundation pattern. Learn how withZod, useActionForm, and error display all fit together before moving to more complex recipes.
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
'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 }
})'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>
)
}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 isSubmittingisPending 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)).
defaultValuesAlways 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
isSubmittingto disable the buttonUse
isPendinginstead. In Next.js the action runs inside a transition, andisSubmittingmay revert tofalsebefore the server response arrives. - ⚠
Calling
redirect()and also returningsuccess: trueredirect()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[] } }shapeThe default
errorMapperonly recognises that exact shape. If your action returns a different format, use a customerrorMapper— see Recipe #11.