Forms Are Harder Than They Look
Validation logic, error messages, loading states, server errors—form handling adds up fast. React Hook Form + Zod eliminates most of the boilerplate.
Setup
npm install react-hook-form @hookform/resolvers zod
Basic Pattern
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Define schema
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
// 2. Infer type from schema
type LoginForm = z.infer<typeof loginSchema>;
// 3. Build form
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginForm) => {
try {
await signIn(data.email, data.password);
} catch (error) {
// Set server-side error on specific field
setError('email', { message: 'Invalid credentials' });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('email')}
type="email"
placeholder="Email"
/>
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input
{...register('password')}
type="password"
placeholder="Password"
/>
{errors.password && <p>{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}
Complex Validation
const signupSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email(),
password: z.string()
.min(8)
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
plan: z.enum(['free', 'pro', 'enterprise']),
acceptTerms: z.boolean().refine(val => val === true, {
message: 'You must accept the terms',
}),
}).refine(data => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
With shadcn/ui Components
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
function SignupForm() {
const form = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
defaultValues: { plan: 'free', acceptTerms: false },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage /> {/* auto-displays error */}
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
Create account
</Button>
</form>
</Form>
);
}
Server Action Integration (Next.js)
'use server';
import { loginSchema } from '@/lib/schemas';
export async function loginAction(formData: FormData) {
const rawData = Object.fromEntries(formData);
const result = loginSchema.safeParse(rawData);
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
const { email, password } = result.data;
// ... authenticate
}
// Client component
'use client';
function LoginForm() {
const [serverErrors, setServerErrors] = useState<Record<string, string[]>>({});
const onSubmit = async (data: LoginForm) => {
const formData = new FormData();
Object.entries(data).forEach(([k, v]) => formData.append(k, String(v)));
const result = await loginAction(formData);
if (result?.error) {
setServerErrors(result.error);
}
};
}
Reusable Form Components
// Generic field component
function FormInput<T extends FieldValues>({
form,
name,
label,
type = 'text',
}: {
form: UseFormReturn<T>;
name: Path<T>;
label: string;
type?: string;
}) {
return (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input type={type} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
// Usage
<FormInput form={form} name="email" label="Email" type="email" />
<FormInput form={form} name="password" label="Password" type="password" />
The Key Benefits
- Single source of truth: Zod schema drives both runtime validation and TypeScript types
- No manual error state: React Hook Form manages all field state
- Performance: Only re-renders affected fields, not the entire form
- Server/client parity: Same Zod schema validates on both sides
The pattern scales from a 2-field login form to a 20-field onboarding wizard without changing your approach.
Forms with Zod validation, server actions, and shadcn/ui components: Whoff Agents AI SaaS Starter Kit.
Top comments (0)