Forms
Forms are where React’s controlled-component model shines — state holds the values, components render them, and validation runs in code you control. React Hook Form and Zod take it from “works” to “production-grade.”
Controlled inputs, validation, libraries
EXAMPLE
// 1) Controlled inputs — React owns the value
import { useState } from 'react';
function SignUpBasic() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [agree, setAgree] = useState(false);
function submit(e) {
e.preventDefault();
if (!email.includes('@')) return alert('Invalid email');
console.log({ name, email, agree });
}
return (
<form onSubmit={submit}>
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} /></label>
<label>Email <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></label>
<label><input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} /> I agree</label>
<button disabled={!agree}>Submit</button>
</form>
);
}
// 2) Uncontrolled (defaultValue + ref) — for simple forms, big perf wins
import { useRef } from 'react';
function Uncontrolled() {
const nameRef = useRef(null);
function submit(e) {
e.preventDefault();
console.log(nameRef.current.value);
}
return (
<form onSubmit={submit}>
<input defaultValue="" ref={nameRef} />
<button>Save</button>
</form>
);
}
// 3) Single state object — scales better
function CardForm() {
const [form, setForm] = useState({ first: '', last: '', email: '', phone: '' });
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
return (
<form onSubmit={(e) => { e.preventDefault(); save(form); }}>
<input value={form.first} onChange={set('first')} placeholder="First" />
<input value={form.last} onChange={set('last')} placeholder="Last" />
<input value={form.email} onChange={set('email')} placeholder="Email" type="email" />
<input value={form.phone} onChange={set('phone')} placeholder="Phone" type="tel" />
<button>Save</button>
</form>
);
}
// 4) React Hook Form + Zod — the modern stack
// npm install react-hook-form @hookform/resolvers zod
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
name: z.string().min(2, 'Too short'),
email: z.string().email('Invalid email'),
age: z.coerce.number().int().min(13, 'Must be 13+'),
password: z.string().min(8, 'Min 8 chars').regex(/[0-9]/, 'Need a number'),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, { message: 'Mismatch', path: ['confirm'] });
type FormData = z.infer<typeof schema>;
function SignUp() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onBlur', // validate on blur (also 'onChange', 'onSubmit')
defaultValues: { name: '', email: '', age: 18, password: '', confirm: '' },
});
async function onSubmit(data: FormData) {
const res = await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
if (!res.ok) alert('failed');
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} placeholder="Name" />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input type="number" {...register('age')} />
{errors.age && <span>{errors.age.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register('confirm')} />
{errors.confirm && <span>{errors.confirm.message}</span>}
<button disabled={isSubmitting}>{isSubmitting ? 'Saving…' : 'Sign up'}</button>
</form>
);
}
// 5) Field array — dynamic rows
import { useFieldArray } from 'react-hook-form';
function Tasks() {
const { control, register, handleSubmit } = useForm({ defaultValues: { tasks: [{ text: '' }] } });
const { fields, append, remove } = useFieldArray({ control, name: 'tasks' });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((f, i) => (
<div key={f.id}>
<input {...register(`tasks.${i}.text`)} />
<button type="button" onClick={() => remove(i)}>×</button>
</div>
))}
<button type="button" onClick={() => append({ text: '' })}>+ Add</button>
<button>Save</button>
</form>
);
}
// 6) File upload
<input type="file" {...register('avatar', { required: true })} accept="image/*" />
async function onSubmit(data) {
const fd = new FormData();
fd.append('avatar', data.avatar[0]);
await fetch('/api/upload', { method: 'POST', body: fd });
}
// 7) Server actions (Next.js 14+) — pure HTML form, no client JS needed
'use server';
async function createUser(formData: FormData) {
const data = Object.fromEntries(formData);
const parsed = schema.parse(data); // server-side validation
await db.user.create({ data: parsed });
}
// <form action={createUser}>...</form>
// 8) Accessibility — get this right!
// • Every input needs a <label htmlFor> or aria-label
// • Use type=email/tel/url to get the right mobile keyboard
// • required + aria-invalid + aria-describedby for error messages
// • Group with <fieldset> / <legend>
// • Submit button keeps default type='submit'; type='button' for non-submit
// • Error messages with role="alert" so screen readers announce them
<label htmlFor="email">Email</label>
<input id="email" type="email" aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-err' : undefined} {...register('email')} />
{errors.email && <span id="email-err" role="alert">{errors.email.message}</span>}
// 9) Optimistic UI for snappy feedback
const [pending, startTransition] = useTransition();
function onSubmit(data) {
startTransition(async () => {
// optimistically update UI; rollback if server errors
setItems((cur) => [...cur, { ...data, id: 'temp' }]);
try { const saved = await api.create(data); setItems((cur) => cur.map((i) => i.id === 'temp' ? saved : i)); }
catch { setItems((cur) => cur.filter((i) => i.id !== 'temp')); }
});
}
// 10) Common bugs
// • Reading state inside onChange w/o setter callback — stale state
// • Value undefined → React warns 'changing uncontrolled to controlled'; init with ''
// • Forgetting e.preventDefault() — full page reload
// • Submit button missing type → type='submit' fires unexpected forms
// • Validating only on client → server MUST re-validate; client guards UX, not security
// • register(name) typo → field never tracks; use TypeScript + generics
// • Async submit without disabling button → double submission
// • New defaultValues per render — reset() doesn't help; pass stable object
Why it matters
Use controlled inputs + React Hook Form + Zod for production forms: declarative validation, minimal re-renders, accessible error display. Always re-validate on the server; client validation is for UX, not security. Field arrays, async submits, and optimistic UI all compose without ceremony.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
function NameForm() {
const [name, setName] = useState('');
return <input value={name} onChange={e => setName(e.target.value)} />;
}
Try it Yourself »
Discussion
Loading…