Article · Apr 24, 2026
How to migrate a useState form to React Hook Form and Zod (the real walkthrough)
Step-by-step migration of one form from useState plus inline validation to React Hook Form plus Zod plus shadcn Form. Code diffs and the mode choice.
If you have a React app from 2022 or earlier, the chances are good that the forms still use useState. One useState per field. One onChange handler per field. A custom validate() function that runs on submit. Maybe a homegrown error-display component. The shape works. It also costs you 80 lines of state plumbing per form and re-renders the entire form on every keystroke.
This tutorial migrates one form to React Hook Form (RHF) plus Zod plus shadcn’s <Form> component. I will use a generic “edit profile” form as the running example. The pattern transfers to any form you have. The total time is about an hour per form once you have done one.
When migration is worth it (and when useState is fine)
useState is fine for forms with three or fewer fields and trivial validation. A login form with email + password. A “subscribe to newsletter” form with one email field. The overhead of RHF + Zod is not worth it for those.
The migration pays off when:
- The form has 5+ fields.
- Validation is non-trivial (cross-field rules, conditional requirements, complex error messages).
- The form has nested objects or array fields (a list of children with name and age each).
- You want server-side errors (a 400 from the API) to display under the offending field, not as a global banner.
- The form re-renders feel slow because every keystroke triggers a parent re-render.
If any two of those apply, migrate.
The before code
Here is the shape I am replacing. It is a profile-edit form with name, email, role, and notes. The validation rules: name required, email required and valid format, role from a fixed list, notes optional but capped at 500 chars.
// EditProfileForm.tsx (before)
import { useState } from 'react';
export function EditProfileForm({ initial, onSave }) {
const [name, setName] = useState(initial.name);
const [email, setEmail] = useState(initial.email);
const [role, setRole] = useState(initial.role);
const [notes, setNotes] = useState(initial.notes ?? '');
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
function validate() {
const errs = {};
if (!name.trim()) errs.name = 'Name is required.';
if (!email.trim()) errs.email = 'Email is required.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errs.email = 'Email format is invalid.';
if (!['admin', 'member', 'viewer'].includes(role))
errs.role = 'Pick a valid role.';
if (notes.length > 500)
errs.notes = 'Notes cannot exceed 500 characters.';
return errs;
}
async function handleSubmit(e) {
e.preventDefault();
const errs = validate();
setErrors(errs);
if (Object.keys(errs).length > 0) return;
setSubmitting(true);
try {
await onSave({ name, email, role, notes });
} catch (err) {
setErrors({ form: err.message });
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<label>Name
<input value={name} onChange={(e) => setName(e.target.value)} />
{errors.name && <span className="err">{errors.name}</span>}
</label>
<label>Email
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{errors.email && <span className="err">{errors.email}</span>}
</label>
<label>Role
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="admin">Admin</option>
<option value="member">Member</option>
<option value="viewer">Viewer</option>
</select>
{errors.role && <span className="err">{errors.role}</span>}
</label>
<label>Notes
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
{errors.notes && <span className="err">{errors.notes}</span>}
</label>
{errors.form && <div className="err">{errors.form}</div>}
<button type="submit" disabled={submitting}>Save</button>
</form>
);
}
About 65 lines, four useState calls for fields and two for state, a hand-rolled validator, a homegrown error model. Every keystroke triggers a re-render of the entire component tree.
Step 1: install the shadcn Form component
The shadcn Form component is a thin wrapper around RHF’s primitives styled with the rest of the shadcn system. Add it once per project:
npx shadcn@latest add form
This drops a components/ui/form.tsx file into your project with the wrappers (<Form>, <FormField>, <FormItem>, <FormLabel>, <FormControl>, <FormMessage>) ready to use. The component depends on react-hook-form and @hookform/resolvers, which the add command installs as well.
Step 2: write the Zod schema and infer the type
The schema is one block. It replaces the hand-rolled validate() function with a declarative shape:
import { z } from 'zod';
export const profileSchema = z.object({
name: z.string().min(1, 'Name is required.'),
email: z.string().min(1, 'Email is required.').email('Email format is invalid.'),
role: z.enum(['admin', 'member', 'viewer'], {
message: 'Pick a valid role.',
}),
notes: z.string().max(500, 'Notes cannot exceed 500 characters.').optional(),
});
export type ProfileInput = z.infer<typeof profileSchema>;
Five lines of schema. The error messages live inside the schema, not scattered through the form code. z.infer gives you the TypeScript type for free, so the form props can be typed as ProfileInput without duplicating the shape.
The validation rules in the schema are exactly the rules in the old validate() function, expressed declaratively. Cross-field validations (e.g. “confirm password matches password”) go in a .refine() block at the bottom of the schema, which is how you express any custom rule.
Step 3: replace useState with useForm
The form component becomes one hook plus a render:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { profileSchema, type ProfileInput } from '@/schemas/profile';
export function EditProfileForm({ initial, onSave }) {
const form = useForm<ProfileInput>({
resolver: zodResolver(profileSchema),
mode: 'onBlur',
defaultValues: {
name: initial.name,
email: initial.email,
role: initial.role,
notes: initial.notes ?? '',
},
});
async function handleSubmit(values: ProfileInput) {
try {
await onSave(values);
} catch (err) {
form.setError('root', { message: err.message });
}
}
// ... render below
}
useForm is the single hook that replaces all the useState calls. resolver: zodResolver(profileSchema) wires the schema in. defaultValues seeds the form with the existing record’s values. mode: 'onBlur' is the validation mode, which I will come back to.
The submit handler receives a typed values object that has already passed the schema. No validate() call, no error-state plumbing. Server-side errors get reported via form.setError('root', { message }), which displays them in a single place at the form level.
Step 4: wrap fields in <FormField>
The render swaps the bare <input> tags for shadcn’s <FormField> primitive, which connects each field to RHF’s state:
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger><SelectValue placeholder="Pick a role" /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl><Textarea {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.formState.errors.root && (
<p className="err">{form.formState.errors.root.message}</p>
)}
<Button type="submit" disabled={form.formState.isSubmitting}>Save</Button>
</form>
</Form>
);
Roughly the same number of lines as the original render, but the structure is doing more work. Each <FormField> wires a field’s validation, error display, and value handling. <FormMessage /> renders the Zod error for that field automatically. form.formState.isSubmitting replaces the manual submitting state.
The total component is now about 50 lines (down from 65), with all the imperative state and validation gone.

Step 5: pick the validation mode
useForm takes a mode option that decides when validation runs. The choices:
onSubmit(the default): validation runs only when the user clicks Save. Errors appear all at once.onBlur: validation runs when a field loses focus. Errors appear as the user tabs through the form.onChange: validation runs on every keystroke. Errors appear immediately as the user types.onTouched: like onBlur on first interaction, then onChange after that.
In practice, onBlur is the right default. It does not yell at users while they are typing (which onChange does and which is annoying for fields like email), but it does flag errors before they click Save and read a wall of red.
onTouched is a nice second choice when you have fields that benefit from immediate feedback after the first edit (password strength meters, conditional reveals). It is more sophisticated than most teams need.
onSubmit is the React default for a reason (it is the least intrusive) but it makes for a bad UX on multi-field forms because the user only finds out about errors after they have committed to the action.
For this profile form, mode: 'onBlur' is what I picked. The user finishes typing their name, tabs to email, and the name field flashes red if they left it blank. They fix it, move on. No banner of errors after submit.
Step 6: handle the submit
The submit handler in the new code is a function that takes a fully-validated ProfileInput object. The handler is responsible for the API call and the server-error mapping:
async function handleSubmit(values: ProfileInput) {
try {
await onSave(values);
toast.success('Profile updated.');
form.reset(values); // Mark the form as clean
} catch (err) {
if (err.code === 'EMAIL_TAKEN') {
form.setError('email', { message: 'Email already in use.' });
} else {
form.setError('root', { message: err.message });
}
}
}
Two patterns matter here.
form.setError('email', { message: ... }) puts the server’s error message under the offending field. The user sees “Email already in use.” right under the email input, not as a banner at the top. This is the UX win that justifies the migration cost.
form.reset(values) after a successful save marks the form as clean (i.e. form.formState.isDirty becomes false). Without this, the Save button stays “dirty” indicator-on even though the values match the server.
Gotcha: controlled vs uncontrolled inputs
RHF uses uncontrolled inputs by default. The value is held by the DOM, not by React state. This is why typing in a field does not re-render the form.
When you wrap a controlled component (a custom Select that holds its own state), use <Controller> from RHF or pass field.onChange and field.value explicitly. The shadcn <Select> example above shows the pattern. The shorthand {...field} spread works for native inputs and most shadcn primitives but not for components that fight RHF for control.
Gotcha: default values and reset
defaultValues sets the form values once, at mount time. If the underlying record changes after mount (e.g. you reload from the server), the form does not pick up the new values unless you call form.reset(newValues).
The pattern:
useEffect(() => {
form.reset({
name: record.name,
email: record.email,
role: record.role,
notes: record.notes ?? '',
});
}, [record.id, form]);
Watch the dependency: [record.id], not [record]. Otherwise any record-object reference change will reset the form mid-edit.
Gotcha: server-side errors and setError
form.setError('field', { message }) is the right primitive for server errors. The error displays under the field, the form stays dirty, the user can fix and resubmit.
form.setError('root', { message }) is for form-level errors (network failure, generic 500). The error displays wherever you render form.formState.errors.root.message, typically as a banner above the submit button.
Errors set with setError clear on the next validation pass. If you set an email error and the user re-edits the email field and blurs, the server-side error goes away. That is usually what you want.
Gotcha: nested objects and array fields
For nested objects, the field name uses dot notation:
<FormField name="address.street" ... />
<FormField name="address.city" ... />
The Zod schema mirrors the shape:
const schema = z.object({
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
}),
});
For arrays (a list of children, a list of tags), use useFieldArray:
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'tags',
});
fields is an array of objects to render; append and remove mutate the list. The schema uses z.array(...) to validate the items.
Putting it together
The original useState form was 65 lines, four useState calls, a hand-rolled validator, an imperative submit handler, and full-tree re-renders on every keystroke.
The migrated form is roughly 50 lines, one useForm hook, a declarative schema in a shared file, server-error handling that puts the message under the right field, and uncontrolled inputs that do not re-render the form on every keystroke.
For a single form the migration takes an hour. For an admin dashboard with six forms, plan a week if you do them in phased migrations with verification gates, or two weeks if you let other priorities interleave. Both are time well spent.
If you are running a React product where the form layer is still on useState and the team is feeling the maintenance cost, let’s talk. The migration is the kind of work I do on AI-assisted projects where the priority is shipping clean, maintainable form code without breaking the existing UX.
For the broader pattern of “ship structural changes one phase at a time,” see phased migrations with per-phase verification gates which covers the dispatch-loop discipline I used to run the same migration across six admin forms without rollbacks. The mode option this post discusses is documented in the React Hook Form useForm reference.