Forms are where users talk to your app. In this part we’ll build elegant, accessible, type-safe forms using React Hook Form (RHF) and Zod for schema validation.
🎯 Goals
- Typed schemas with Zod → auto‑inferred TS types (always in sync)
- RHF + zodResolver for instant, user‑friendly validation
- Reusable Input that shows errors + accessibility labels
- Field Arrays (subtasks), default values, reset, watch
- Async validation & Axios submission with error handling
We’ll implement an Add/Edit Task form for TaskTimer.
0) Install (if you haven’t already)
pnpm add react-hook-form zod @hookform/resolvers axios
1) Define a Zod schema (single source of truth)
// src/features/forms/schemas.ts
import { z } from 'zod';
// Zod schema describes shape + validation
export const taskSchema = z.object({
title: z
.string()
.min(3, 'Title must be at least 3 characters')
.max(50, 'Title must be 50 characters or less'),
notes: z
.string()
.max(200, 'Notes must be 200 characters or less')
.optional()
// treat empty strings as undefined so optional really feels optional
.or(z.literal('').transform(() => undefined)),
dueDate: z
.string()
.optional()
.refine((v) => !v || !Number.isNaN(Date.parse(v)), { message: 'Invalid date' }),
priority: z.enum(['low', 'medium', 'high']).default('medium'),
subtasks: z
.array(
z.object({
id: z.string(),
text: z.string().min(1, 'Subtask cannot be empty'),
done: z.boolean().default(false),
}),
)
.max(10, 'Too many subtasks')
.default([]),
});
// Infer the TypeScript type from schema (always in sync)
export type TaskForm = z.infer<typeof taskSchema>;
Why this matters: You write rules once in Zod and get both runtime validation and compile‑time types.
2) RHF setup with zodResolver
// src/features/forms/TaskForm.tsx
import { useForm, FormProvider, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { TaskForm } from './schemas';
import { taskSchema } from './schemas';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
export function TaskFormView({
onSubmit,
initial,
}: {
onSubmit: (data: TaskForm) => Promise<void> | void;
initial?: Partial<TaskForm>; // allow partial for edit forms
}) {
// Create the form with resolver + defaults
const methods = useForm<TaskForm>({
resolver: zodResolver(taskSchema),
mode: 'onBlur', // validate when field loses focus
defaultValues: {
title: '',
notes: '',
dueDate: '',
priority: 'medium',
subtasks: [],
...initial,
},
});
// Field array for dynamic subtasks
const { fields, append, remove } = useFieldArray({
name: 'subtasks',
control: methods.control,
});
// Convenience
const {
register, // connects inputs to RHF
handleSubmit, // wraps your submit handler with validation
formState: { errors, isSubmitting }, // errors map + submitting flag
reset,
watch,
} = methods;
const priority = watch('priority'); // example of live watch
return (
// FormProvider lets nested components access the form via useFormContext
<FormProvider {...methods}>
<form
onSubmit={handleSubmit(async (data) => {
await onSubmit(data); // parent handles persistence
reset(); // clear after success (optional)
})}
className="flex flex-col gap-3 max-w-lg"
noValidate // rely on Zod/RHF messages, not default browser tooltips
>
{/* Title */}
<Input
label="Title"
placeholder="e.g., Write Part 6 blog"
// register wires the input to RHF under the key "title"
{...register('title')}
error={errors.title?.message}
/>
{/* Notes (optional) */}
<Input
label="Notes"
placeholder="Optional notes…"
{...register('notes')}
error={errors.notes?.message}
/>
{/* Due date (string ISO date for simplicity) */}
<Input
label="Due Date"
type="date"
{...register('dueDate')}
error={errors.dueDate?.message}
/>
{/* Priority (radio group example) */}
<fieldset className="border rounded p-3">
<legend className="font-medium">Priority</legend>
<label className="mr-4">
<input type="radio" value="low" {...register('priority')} /> Low
</label>
<label className="mr-4">
<input type="radio" value="medium" {...register('priority')} /> Medium
</label>
<label>
<input type="radio" value="high" {...register('priority')} /> High
</label>
<p className="text-sm text-gray-500 mt-1">Selected: {priority}</p>
{errors.priority && (
<span className="text-red-500 text-sm">{errors.priority.message}</span>
)}
</fieldset>
{/* Subtasks (dynamic list) */}
<div className="border rounded p-3">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">Subtasks</span>
<Button type="button" variant="secondary" onClick={() =>
append({ id: crypto.randomUUID(), text: '', done: false })
}>
+ Add Subtask
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-gray-500">No subtasks yet.</p>
)}
<ul className="flex flex-col gap-2">
{fields.map((field, index) => (
<li key={field.id} className="flex items-center gap-2">
{/* Keep key as field.id from RHF for stability */}
<input
className="border rounded px-2 py-1 flex-1"
placeholder={`Subtask #${index + 1}`}
// Register nested field via index path
{...register(`subtasks.${index}.text`)}
/>
<label className="flex items-center gap-1 text-sm">
<input type="checkbox" {...register(`subtasks.${index}.done`)} /> done
</label>
<Button type="button" variant="danger" onClick={() => remove(index)}>
Remove
</Button>
{/* Show per‑item error if any */}
{errors.subtasks?.[index]?.text?.message && (
<span className="text-red-500 text-sm">
{errors.subtasks?.[index]?.text?.message}
</span>
)}
</li>) )}
</ul>
{/* Array‑level errors (e.g., max length) */}
{typeof errors.subtasks?.message === 'string' && (
<span className="text-red-500 text-sm">{errors.subtasks?.message}</span>
)}
</div>
<div className="flex gap-2 justify-end mt-2">
<Button type="button" variant="secondary" onClick={() => reset()}>
Reset
</Button>
<Button type="submit" isLoading={isSubmitting}>Save Task</Button>
</div>
</form>
</FormProvider>
);
}
Notes
register('field')connects inputs; RHF manages value/blur/change.errors.field?.messageshows the Zod message.useFieldArrayhandles dynamic lists with stable keys.
3) Submitting with Axios (+ API layer)
// src/lib/api/http.ts (from earlier parts)
import axios from 'axios';
export const http = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10_000,
});
http.interceptors.response.use(
(r) => r,
(err) => {
const status = err.response?.status ?? 0;
const message = err.response?.data?.message ?? err.message;
return Promise.reject({ status, message } as { status: number; message: string });
},
);
// src/lib/api/tasks.ts
import { http } from './http';
import type { TaskForm } from '@/features/forms/schemas';
export async function createTask(data: TaskForm) {
// Map to server DTO if needed
const res = await http.post('/tasks', data);
return res.data as { id: string };
}
// Example usage: wrap TaskFormView and send to API
import { TaskFormView } from '@/features/forms/TaskForm';
import { createTask } from '@/lib/api/tasks';
export default function NewTaskPage() {
return (
<section>
<h2 className="text-xl font-semibold mb-3">New Task</h2>
<TaskFormView
onSubmit={async (data) => {
try {
await createTask(data);
alert('✅ Task saved'); // replace with a toast lib in real app
} catch (e: any) {
alert(`❌ Failed: ${e.message ?? 'Unknown error'}`);
}
}}
/>
</section>
);
}
4) Controlled vs Uncontrolled (when to use Controller)
Most HTML inputs work with register. For custom components (like date pickers), use Controller.
import { Controller, useFormContext } from 'react-hook-form';
// Example custom select component integration
function PrioritySelect() {
const { control } = useFormContext();
return (
<Controller
name="priority"
control={control}
render={({ field, fieldState }) => (
<div>
<select {...field} className="border rounded px-2 py-1">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
{fieldState.error && (
<span className="text-red-500 text-sm">{fieldState.error.message}</span>
)}
</div>
)}
/>
);
}
5) Async field validation (e.g., unique title)
// Debounced async validation example
import { useFormContext } from 'react-hook-form';
import { useEffect } from 'react';
async function isTitleTaken(title: string) {
// pretend API call
await new Promise((r) => setTimeout(r, 300));
return title.toLowerCase() === 'react';
}
export function TitleAvailabilityHint() {
const { watch, setError, clearErrors } = useFormContext<TaskForm>();
const title = watch('title');
useEffect(() => {
let active = true;
const run = async () => {
if (title.length < 3) return; // skip until basic rule passes
const taken = await isTitleTaken(title);
if (!active) return;
if (taken) setError('title', { type: 'validate', message: 'Title already taken' });
else clearErrors('title');
};
run();
return () => { active = false; };
}, [title, setError, clearErrors]);
return null; // this component only manages validation side-effects
}
Use it inside the form near Title (optional enhancement).
6) Accessibility & UX checklist
- Always use a label for inputs (our
Inputadds it). - Connect error text to inputs via
aria-describedby(ourInputcan be extended to set IDs). - Prefer onBlur validation to reduce noise; show errors near fields.
- Disable submit while
isSubmitting; show progress (we usedisLoading).
7) Common pitfalls & fixes
- Forgetting
defaultValues→ fields become uncontrolled. Always set initial values. - Mixing controlled & uncontrolled → don’t pass both
valueanddefaultValueunless you intend to control. - Not using schema inference → import
TaskFormfrom Zod inference, don’t duplicate types. - Dynamic lists without stable keys → use
field.idfromuseFieldArray.
✅ Wrap‑Up
You now have a fully typed form pipeline:
- Zod schema → single source of truth for validation + types
- React Hook Form → performant, ergonomic form state
- Field arrays, async validation, and API submission with Axios
👉 Next: We’ll manage server state with TanStack Query (typed queries/mutations, caching, optimistic updates) and connect it to our Task API for a smooth, real‑world experience. 🚀
Comments
Post a Comment