iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…