Introduction
Building forms in frontend development often turns into a "UI is easy, validation is hard" dilemma. React Hook Form (RHF) provides performant form state management, while Zod offers a powerful schema/validation layer with excellent TypeScript compatibility.
In this article, we'll clearly set up the following:
- A single-source schema with RHF + Zod
- Form fields across 3 different UI kits (Ant Design, Material UI, shadcn/ui) using the same schema
- Proper integration for Input, Select, and Checkbox fields
- Correctly binding error messages to UI components
Note: Regardless of the UI library, the core approach is the same: RHF Controller
The most time-consuming part when building forms with UI kits is usually this: "Does this input need register or Controller? Why isn't Select accepting a value? Why is the checkbox behaving backwards?"
In this article, we'll use a single standard:
- Validation: Zod
- Form state: React Hook Form
- UI: Ant Design, Material UI (MUI), shadcn/ui
- Rule: No register → only Controller
Installation
npm i react-hook-form zod @hookform/resolvers
Zod Schema
All form validation rules are defined in a single Zod schema. This way, business logic is completely separated from the UI, and we get the same validation behavior regardless of which component library we use. The schema provides both runtime validation and TypeScript type safety simultaneously.
Example "Registration" form:
- name: minimum 2 characters
- email: valid email
- password: minimum 8 characters + at least 1 digit
- role: user/admin
- terms: must be true
import { z } from 'zod';
export const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Enter a valid email'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/\d/, 'Password must contain at least 1 digit'),
role: z.enum(['user', 'admin'], { message: 'You must select a role' }),
terms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
});
export type RegisterFormValues = z.infer<typeof registerSchema>;
RHF + Zod with Ant Design (antd)
We connect Ant Design components to React Hook Form exclusively through Controller, centralizing all form control in one place. While the Zod schema handles validation, antd serves purely as the visual layer. This way, controlled components like Select, Input, and Checkbox work consistently and predictably.
Note: I'm assuming AntD is already installed in your project.
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, Input, Select, Checkbox } from 'antd';
import { registerSchema, type RegisterFormValues } from './schema';
export default function RegisterFormAntd() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: '',
email: '',
password: '',
role: 'user',
terms: false,
},
mode: 'onBlur',
});
const onSubmit = (data: RegisterFormValues) => {
console.log('ANTD SUBMIT:', data);
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{ display: 'grid', gap: 12, maxWidth: 420 }}
>
<div>
<Controller
name="name"
control={control}
render={({ field }) => (
<Input {...field} placeholder="Name" status={errors.name ? 'error' : ''} />
)}
/>
{errors.name && <div style={{ color: '#ff4d4f' }}>{errors.name.message}</div>}
</div>
<div>
<Controller
name="email"
control={control}
render={({ field }) => (
<Input {...field} placeholder="Email" status={errors.email ? 'error' : ''} />
)}
/>
{errors.email && <div style={{ color: '#ff4d4f' }}>{errors.email.message}</div>}
</div>
<div>
<Controller
name="password"
control={control}
render={({ field }) => (
<Input.Password
{...field}
placeholder="Password"
status={errors.password ? 'error' : ''}
/>
)}
/>
{errors.password && (
<div style={{ color: '#ff4d4f' }}>{errors.password.message}</div>
)}
</div>
<div>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
options={[
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' },
]}
status={errors.role ? 'error' : ''}
/>
)}
/>
{errors.role && <div style={{ color: '#ff4d4f' }}>{errors.role.message}</div>}
</div>
<div>
<Controller
name="terms"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onChange={e => field.onChange(e.target.checked)}
onBlur={field.onBlur}
>
I accept the terms
</Checkbox>
)}
/>
{errors.terms && <div style={{ color: '#ff4d4f' }}>{errors.terms.message}</div>}
</div>
<Button htmlType="submit" type="primary" loading={isSubmitting}>
Sign Up
</Button>
</form>
);
}
RHF + Zod with Material UI (MUI)
When working with Material UI, we connect all fields through Controller, delegating form control entirely to React Hook Form. While the Zod schema manages validation, MUI components serve solely as the visual layer. This way, error handling (error, helperText) is fed directly from RHF state, keeping form behavior consistent.
Note: I'm assuming MUI is already installed in your project.
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, TextField, MenuItem, Checkbox, FormControlLabel } from '@mui/material';
import { registerSchema, type RegisterFormValues } from './schema';
export default function RegisterFormMUI() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: '',
email: '',
password: '',
role: 'user',
terms: false,
},
mode: 'onBlur',
});
const onSubmit = (data: RegisterFormValues) => {
console.log('MUI SUBMIT:', data);
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
style={{ display: 'grid', gap: 12, maxWidth: 420 }}
>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
label="Name"
{...field}
error={!!errors.name}
helperText={errors.name?.message}
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
label="Email"
{...field}
error={!!errors.email}
helperText={errors.email?.message}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field }) => (
<TextField
label="Password"
type="password"
{...field}
error={!!errors.password}
helperText={errors.password?.message}
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<TextField
select
label="Role"
{...field}
error={!!errors.role}
helperText={errors.role?.message}
>
<MenuItem value="user">User</MenuItem>
<MenuItem value="admin">Admin</MenuItem>
</TextField>
)}
/>
<div>
<Controller
name="terms"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={e => field.onChange(e.target.checked)}
onBlur={field.onBlur}
/>
}
label="I accept the terms"
/>
)}
/>
{errors.terms && <div style={{ color: 'crimson' }}>{errors.terms.message}</div>}
</div>
<Button type="submit" variant="contained" disabled={isSubmitting}>
Sign Up
</Button>
</form>
);
}
RHF + Zod with shadcn/ui
shadcn/ui (Radix-based) components are controlled by nature, so we connect all fields with Controller, delegating form state management entirely to React Hook Form. While the Zod schema handles validation, shadcn works purely as the UI layer. This approach ensures that even components with custom event structures like Select and Checkbox are managed consistently and predictably.
Note: I'm assuming shadcn/ui is already installed in your project.
shadcn (Radix) components are controlled and some return a direct value instead of an event:
- Select: onValueChange(value)
- Checkbox: onCheckedChange(checked) → true | false | "indeterminate"
import React from 'react';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, type RegisterFormValues } from './schema';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export default function RegisterFormShadcn() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
name: '',
email: '',
password: '',
role: 'user',
terms: false,
},
mode: 'onBlur',
});
const onSubmit = (data: RegisterFormValues) => {
console.log('SHADCN SUBMIT:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-3 max-w-[420px]">
<div>
<Controller
name="name"
control={control}
render={({ field }) => <Input placeholder="Name" {...field} />}
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
)}
</div>
<div>
<Controller
name="email"
control={control}
render={({ field }) => <Input placeholder="Email" {...field} />}
/>
{errors.email && (
<p className="text-sm text-destructive mt-1">{errors.email.message}</p>
)}
</div>
<div>
<Controller
name="password"
control={control}
render={({ field }) => (
<Input type="password" placeholder="Password" {...field} />
)}
/>
{errors.password && (
<p className="text-sm text-destructive mt-1">{errors.password.message}</p>
)}
</div>
<div>
<Controller
name="role"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
)}
/>
{errors.role && (
<p className="text-sm text-destructive mt-1">{errors.role.message}</p>
)}
</div>
<div>
<div className="flex items-center gap-2">
<Controller
name="terms"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={checked => field.onChange(checked === true)}
/>
)}
/>
<span className="text-sm">I accept the terms</span>
</div>
{errors.terms && (
<p className="text-sm text-destructive mt-1">{errors.terms.message}</p>
)}
</div>
<Button type="submit" disabled={isSubmitting}>
Sign Up
</Button>
</form>
);
}
A Few Quick Notes
The reason we pass our validation schema into zodResolver is that React Hook Form is library-agnostic — it doesn't understand Zod's language on its own. That's why we also install the @hookform/resolvers package alongside React Hook Form. It acts as a translator between Zod and React Hook Form, enabling validations to be integrated into the form.
mode: "onBlur" specifies when error checking is triggered — the user clicks an input, types their text, and the moment they click elsewhere (when focus is lost), validation kicks in.
Most Common Mistakes in the Controller-Only Approach
Select value is empty on submit > Cause: The Select's value/onChange mapping is incorrectly configured for the UI kit.
- antd: onChange(value)
- shadcn: onValueChange(value)
Checkbox behaves backwards > Cause: checked is needed instead of value.
- antd/MUI: e.target.checked
- shadcn: A checked === true guard is recommended
Zod messages aren't showing > Cause: errors.field?.message isn't bound to the UI.
- MUI: helperText
- antd & shadcn: custom error text
Conclusion
In modern frontend projects, form management goes far beyond just UI. What truly matters is managing validation, type safety, and form behavior in a consistent manner.
- Zod is the single source of truth for the form.
- React Hook Form manages form state with performance and minimal re-renders.
- The Controller-only approach provides a standardized integration model regardless of the UI library.
Whether you use Ant Design, Material UI, or shadcn/ui — it doesn't matter. When business logic (schema) is separated from the presentation layer (UI), form architecture becomes simpler, more scalable, and cheaper to maintain.
In short: One schema. One integration model. UI-agnostic form architecture.
This approach provides a significant advantage, especially in large-scale projects or setups where you switch between different UI kits.
Links
You can find the official documentation for the following libraries here:
