111: Advanced Forms — React Hook Form, Zod, Server Errors, and Accessibility
Learning objectives
You will learn to:
- choose controlled versus uncontrolled form architecture;
- use React Hook Form for larger form state;
- validate parsed values with Zod;
- integrate
zodResolver; - handle nested fields and field arrays;
- map server validation errors back to fields;
- preserve form values after failure;
- manage focus and accessible error descriptions;
- coordinate forms with React Actions or TanStack Query mutations.
When local useState is enough
For a two-field form:
const [title, setTitle] =
useState('');
is completely reasonable.
Do not install a form library automatically.
A form library becomes useful when you have:
- many fields;
- nested structures;
- field arrays;
- complex validation;
- touched/dirty semantics;
- repeated server error mapping;
- performance pressure from controlled rerenders.
Install
npm install react-hook-form zod @hookform/resolvers
Schema
import {
z,
} from 'zod';
export const taskSchema =
z.object({
title:
z.string()
.trim()
.min(
3,
'Use at least 3 characters.',
)
.max(
80,
'Use 80 characters or fewer.',
),
priority:
z.enum([
'low',
'normal',
'high',
]),
dueDate:
z.string()
.optional(),
});
Zod parses unknown input into a validated shape.
Do not confuse client schema validation with server authorization.
React Hook Form setup
import {
useForm,
} from 'react-hook-form';
import {
zodResolver,
} from '@hookform/resolvers/zod';
function TaskForm({
onSubmitTask,
}) {
const {
register,
handleSubmit,
setError,
reset,
formState: {
errors,
isSubmitting,
isDirty,
},
} =
useForm({
resolver:
zodResolver(
taskSchema,
),
defaultValues: {
title: '',
priority:
'normal',
dueDate: '',
},
});
async function submit(
values,
) {
await onSubmitTask(
values,
);
}
return (
<form
onSubmit={
handleSubmit(
submit,
)
}
noValidate
>
...
</form>
);
}
noValidate is appropriate here only because the exercise intentionally demonstrates custom error rendering. Native browser validation remains valuable when it matches the desired UX.
Accessible field error
function TitleField({
register,
error,
}) {
const errorId =
'title-error';
return (
<div>
<label htmlFor="title">
Title
</label>
<input
id="title"
{
...register(
'title',
)
}
aria-invalid={
Boolean(error)
}
aria-describedby={
error
? errorId
: undefined
}
/>
{error && (
<p
id={errorId}
role="alert"
>
{
error.message
}
</p>
)}
</div>
);
}
Error color alone is insufficient.
The control needs a programmatic relationship to the message.
Complete form
function TaskForm({
createTask,
}) {
const {
register,
handleSubmit,
setError,
reset,
setFocus,
formState: {
errors,
isSubmitting,
},
} =
useForm({
resolver:
zodResolver(
taskSchema,
),
defaultValues: {
title: '',
priority:
'normal',
dueDate: '',
},
});
async function submit(
values,
) {
try {
const task =
await createTask(
values,
);
reset();
return task;
} catch (error) {
if (
error.status === 422
&& error.body?.errors
) {
const entries =
Object.entries(
error.body
.errors,
);
for (
const [
field,
message,
] of entries
) {
setError(
field,
{
type:
'server',
message,
},
);
}
const firstField =
entries[0]?.[0];
if (firstField) {
setFocus(
firstField,
);
}
return;
}
setError(
'root.server',
{
type: 'server',
message:
'Could not save. Try again.',
},
);
}
}
return (
<form
onSubmit={
handleSubmit(
submit,
)
}
noValidate
>
<label htmlFor="title">
Title
</label>
<input
id="title"
{
...register(
'title',
)
}
aria-invalid={
Boolean(
errors.title,
)
}
aria-describedby={
errors.title
? 'title-error'
: undefined
}
/>
{errors.title && (
<p
id="title-error"
role="alert"
>
{
errors
.title
.message
}
</p>
)}
<label
htmlFor="priority"
>
Priority
</label>
<select
id="priority"
{
...register(
'priority',
)
}
>
<option value="low">
Low
</option>
<option value="normal">
Normal
</option>
<option value="high">
High
</option>
</select>
{errors.root?.server
&& (
<p role="alert">
{
errors.root
.server
.message
}
</p>
)}
<button
disabled={
isSubmitting
}
>
{isSubmitting
? 'Saving…'
: 'Save'}
</button>
</form>
);
}
Field arrays
For repeated inputs:
const {
fields,
append,
remove,
} =
useFieldArray({
control,
name: 'checklist',
});
Render using the field-generated stable ID:
{fields.map(
(field, index) => (
<div key={field.id}>
<input
{
...register(
`checklist.${index}.title`,
)
}
/>
<button
type="button"
onClick={() =>
remove(index)
}
>
Remove
</button>
</div>
),
)}
Do not use array index as the React key for dynamic field arrays when the library supplies a stable field ID.
Server validation contract
A predictable server response helps client mapping.
Example:
{
"error": {
"code": "VALIDATION_ERROR",
"fields": {
"title": "Already exists"
}
}
}
The server may reject values that passed client validation because of:
- database uniqueness;
- authorization;
- stale business rules;
- concurrent changes.
That is normal.
TanStack Query mutation integration
const mutation =
useMutation({
mutationFn:
createTask,
onSuccess:
async () => {
await queryClient
.invalidateQueries({
queryKey:
['tasks'],
});
},
});
Then submit:
await mutation
.mutateAsync(values);
Use mutateAsync when your form workflow needs Promise control.
Still use the v5 object syntax for useMutation.
React Action integration
React Actions can own pending/form-state semantics.
RHF can remain useful for complex client field management.
Do not combine every form tool by default. Choose based on actual needs.
Dirty state
If navigating away from a dirty form, decide product behavior:
- allow leaving;
- warn;
- autosave;
- persist draft.
Do not show a browser confirmation for every tiny form without considering UX.
Reset behavior
After successful create:
reset();
After successful edit:
reset(savedTask);
Do not reset immediately when submit starts; preserve user input if the request fails.
Common mistakes
- client validation treated as security;
- losing values on 422;
- root error for every field problem;
- errors not connected with
aria-describedby; - disabling submit forever after one error;
- index keys in dynamic arrays;
- controlling every field unnecessarily;
- duplicating server errors in several state systems.
Exercises
- Build a task form with RHF + Zod.
- Map a simulated 422 to field errors.
- Add a dynamic checklist with
useFieldArray. - Focus the first server-invalid field.
- Integrate a TanStack Query v5 mutation.
- Test with keyboard only and a screen-reader accessibility tree.
Exit questions
- When is plain
useStateenough? - What does Zod provide?
- Why is server validation still authoritative?
- How should field errors be associated with inputs?
- When should a form reset?
- How can RHF and TanStack Query divide responsibilities?
Official references
- https://react-hook-form.com/
- https://zod.dev/
- https://github.com/react-hook-form/resolvers
- https://react.dev/reference/react-dom/components/form
Deep dive: forms are state machines even when the library hides the mechanics
A realistic form can be:
pristine dirty validating invalid submitting server-invalid success unexpected-error
Do not reduce every form to:
isLoading boolean
React Hook Form provides useful state fields, but you still need to decide what the product should do in each state.
Default values
Provide stable default values:
useForm({
defaultValues: {
title: '',
priority: 'normal',
assigneeId: '',
tags: [],
},
});
For async edit forms, decide architecture carefully.
Possible pattern:
const taskQuery = useQuery(...);
if (taskQuery.isPending) return <Skeleton />;
return (
<TaskForm
key={taskQuery.data.task.id}
defaultValues={mapTaskToForm(taskQuery.data.task)}
/>
);
The key intentionally creates a fresh form for a different task identity.
Do not continuously call reset(serverData) on every background refetch if user has unsaved edits.
Zod preprocessing/coercion
HTML numeric inputs submit strings.
Schema:
const schema = z.object({
estimateHours: z.coerce
.number()
.min(0)
.max(1000),
});
But coercion has edge cases:
empty string whitespace NaN
Test actual values.
Sometimes preprocessing is clearer:
const optionalNumber = z.preprocess(
(value) => {
if (value === '') return undefined;
return Number(value);
},
z.number().finite().optional(),
);
Cross-field validation
const scheduleSchema = z
.object({
startDate: z.string(),
endDate: z.string(),
})
.superRefine((value, context) => {
const start = new Date(value.startDate);
const end = new Date(value.endDate);
if (end < start) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['endDate'],
message: 'End date must be after start date.',
});
}
});
Client rule helps UX.
Server repeats rule using authoritative timezone/business policy.
Controller for non-native controlled components
Simple native inputs work well with register.
A custom controlled component may use Controller:
<Controller
name="assigneeId"
control={control}
render={({ field, fieldState }) => (
<UserSelect
value={field.value}
onValueChange={field.onChange}
error={fieldState.error?.message}
/>
)}
/>
Do not use Controller for every plain input unnecessarily.
Field arrays and identity
const { fields, append, remove, move } = useFieldArray({
control,
name: 'checklist',
});
Use:
key={field.id}
not index.
If server items already have IDs, distinguish library field identity from domain ID carefully.
Do not overwrite RHF-generated identity property unintentionally.
Nested server errors
Server:
{
"error": {
"fields": {
"checklist.2.title": "Required"
}
}
}
Mapping helper:
for (const [path, message] of Object.entries(serverErrors)) {
setError(path, {
type: 'server',
message,
});
}
Validate that paths correspond to allowed fields before blindly trusting arbitrary server keys if your UI/helper logic could be abused.
Root errors
Use form/root error for failures not tied to one field:
setError('root.server', {
type: 'server',
message: 'Could not save changes.',
});
Examples:
- network failure;
- 409 record conflict;
- server unavailable.
A 409 may deserve richer conflict UI rather than one message.
Focus and error summary
For a long form:
<section role="alert" tabIndex={-1} ref={summaryRef}>
<h2>Check 3 fields</h2>
<ul>...</ul>
</section>
After failed submit, focus summary or first invalid control according to tested UX.
Error summary links can move focus to fields.
Do not move focus repeatedly while user corrects input.
Dirty field conflict handling
Edit workflow:
- task loaded version 4;
- user modifies title;
- server task becomes version 5 elsewhere;
- save returns 409.
Options:
- show latest server values next to local draft;
- allow overwrite with explicit confirmation;
- merge non-conflicting fields;
- reload/discard local changes.
Form library cannot decide this business policy.
shouldUnregister
Dynamic fields can stay registered or unregister when unmounted depending configuration.
This affects:
- hidden conditional values;
- wizard steps;
- validation;
- payload.
Explicitly decide whether a hidden field should still submit.
Multi-step forms
State ownership options:
- one RHF instance across steps;
- route/URL per step;
- client store for draft;
- server draft resource.
For long business workflows, server-side draft persistence may be safer than keeping everything in memory.
A browser refresh should not necessarily destroy an hour of user work.
File uploads
RHF can register file input, but actual upload architecture requires:
- multipart/FormData or signed upload workflow;
- size/type validation;
- progress;
- cancellation;
- retry;
- server scanning/policy.
Do not serialize File into JSON.
TanStack Query v5 integration details
const mutation = useMutation({
mutationFn: createTask,
});
async function submit(values) {
try {
const task = await mutation.mutateAsync(values);
reset(mapTaskToForm(task));
await queryClient.invalidateQueries({
queryKey: ['tasks'],
});
} catch (error) {
mapApiErrors(error, setError);
}
}
Be careful: if onSuccess already invalidates, do not duplicate invalidation in both hook options and submit function unless intentional.
Keep mutation responsibility in one place.
React Actions versus RHF
Simple form:
React Action + native form
may be enough.
Complex dynamic form:
RHF + Zod + mutation/Action
may justify a library.
Do not combine tools because the course taught them.
Architecture decision:
What complexity does this tool remove? What duplicate state would it create?
Accessibility beyond labels
Audit:
- fieldset/legend for grouped radios/checkboxes;
- required indication conveyed programmatically;
- autocomplete tokens;
- inputmode;
- error relationship;
- disabled versus readonly semantics;
- status messages;
- focus;
- keyboard order.
Do not wrap every error in role="alert" if validation updates on every keystroke; constant announcements can become noisy.
Testing forms
Test behaviors:
valid submit required error server 422 server 409 server 500 double submit keyboard reset after success preserve after failure dynamic field add/remove
Use user-event, not direct DOM value assignment.
Failure clinic
Schema mismatch with API
Client accepts string but server expects null/omitted.
Define mapping layer.
Reset after submit starts
User loses draft on failure.
Controlled custom widget not wired through Controller
Form state never updates.
Background query reset overwrites dirty edits
Ownership bug.
Exercises
- Build async edit form with intentional reset-on-ID strategy.
- Add
Controlleraround a custom select. - Add cross-field schedule validation.
- Map nested 422 errors.
- Add a 409 conflict UI.
- Build multi-step form and decide persistence ownership.
- Test file field semantics without sending JSON.
- Conduct accessibility audit.
Mastery check
Explain:
- RHF ownership model;
registerversusController;- default/reset strategy;
- field-array identity;
- server error mapping;
- dirty conflict handling;
- why form tooling never replaces server validation.
Production case study: edit form versus background server refresh
Task query returns:
{
"id": "t1",
"title": "Prepare invoice",
"priority": "normal",
"version": 3
}
User opens editor and changes title locally.
While typing, Query refetch returns version 4 because another user changed priority.
Do not blindly:
useEffect(() => {
reset(taskQuery.data);
}, [taskQuery.data, reset]);
That can overwrite the title draft.
A safer strategy:
form initialized from version 3 draft owns edits background server version 4 noted separately save includes base version 3 server returns 409 UI offers reload/merge
This is real collaborative editing behavior.
For simple single-user forms, you may choose to disable background refetch while editing or reset only when entity ID changes.
Form architecture depends on concurrency requirements, not only validation library APIs.
Additional depth: watch, useWatch, and avoiding form-wide rerenders
React Hook Form can observe field values.
Broad:
const values = watch();
can cause more component work because the component observes the whole form.
Narrow:
const priority = useWatch({
control,
name: 'priority',
});
is useful when one UI region depends on one field.
Example:
function PriorityHelp({ control }) {
const priority = useWatch({
control,
name: 'priority',
});
if (priority !== 'high') {
return null;
}
return (
<p>
High-priority tasks notify the assigned team immediately.
</p>
);
}
Use narrow subscriptions for large forms.
Programmatic updates
setValue('priority', 'high', {
shouldDirty: true,
shouldValidate: true,
});
Use when product interaction changes a field outside normal input event.
Do not programmatically mirror every field into React state.
getValues
getValues() reads current form data without subscribing component to updates.
Useful in event logic:
function preview() {
const values = getValues();
openPreview(values);
}
Use the right form API based on whether you need:
reactive render subscription or one-time event read
This is the same ownership principle seen throughout React.
