Step-by-step
-
1
Choose: one state object vs separate useState
For a form with more than 2–3 fields, store all field values in a single state object. It makes the reset trivial (one
setStatecall), and theonChangehandler is reusable across all fields. SeparateuseStatecalls for each field get unwieldy fast and scatter your reset logic.jsxconst initialValues = { name: '', email: '', password: '', role: 'user', }; function SignupForm() { const [formData, setFormData] = useState(initialValues); function handleChange(e) { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); } // All inputs share this one handler via the `name` attribute } -
2
Wire up controlled inputs
A controlled input has both
value(from state) andonChange(updates state). Withoutvalue, the input is uncontrolled and React cannot track what is in it. WithoutonChange, the input is read-only and the user cannot type.The
nameattribute must match the key informData— that is how the single handler knows which field to update.jsxreturn ( <form onSubmit={handleSubmit}> <div> <label htmlFor="name">Name</label> <input id="name" name="name" type="text" value={formData.name} onChange={handleChange} /> </div> <div> <label htmlFor="email">Email</label> <input id="email" name="email" type="email" value={formData.email} onChange={handleChange} /> </div> <div> <label htmlFor="password">Password</label> <input id="password" name="password" type="password" value={formData.password} onChange={handleChange} /> </div> <button type="submit">Sign up</button> </form> ); -
3
Write the validation function
Keep validation logic in a pure function that takes the form values and returns an errors object. This makes it easy to unit-test independently of the component. Return an empty object when the form is valid.
javascriptfunction validate(values) { const errors = {}; if (!values.name.trim()) { errors.name = 'Name is required.'; } if (!values.email.trim()) { errors.email = 'Email is required.'; } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) { errors.email = 'Enter a valid email address.'; } if (!values.password) { errors.password = 'Password is required.'; } else if (values.password.length < 8) { errors.password = 'Password must be at least 8 characters.'; } return errors; // empty object = valid } -
4
Validate on blur, show errors inline
Validate on blur (when a field loses focus), not on every keystroke. Showing errors while the user is mid-typing is annoying. On submit, validate all fields at once regardless of touch state.
Store errors in state and display them immediately below each field. Associate them with the input via
aria-describedbyfor accessibility.jsxconst [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); function handleBlur(e) { const { name } = e.target; setTouched(prev => ({ ...prev, [name]: true })); // Validate only the touched field const fieldErrors = validate(formData); setErrors(prev => ({ ...prev, [name]: fieldErrors[name] })); } // In JSX, below each input: // {touched.email && errors.email && ( // <span id="email-error" role="alert">{errors.email}</span> // )} // // On the input itself: // aria-describedby={errors.email ? 'email-error' : undefined} // aria-invalid={!!errors.email} -
5
Handle submit with full validation
On submit, validate every field (ignoring the touched state — the user clicked submit, show all errors). If there are any errors, focus the first invalid field and bail out. Otherwise, proceed with the API call.
jsxconst [submitting, setSubmitting] = useState(false); async function handleSubmit(e) { e.preventDefault(); const fieldErrors = validate(formData); if (Object.keys(fieldErrors).length > 0) { setErrors(fieldErrors); // Mark all fields as touched so errors are visible setTouched({ name: true, email: true, password: true, role: true }); return; } setSubmitting(true); try { await api.signup(formData); setFormData(initialValues); // clear form on success setErrors({}); setTouched({}); // navigate or show success message } catch (err) { setErrors({ form: err.message }); // server-level error } finally { setSubmitting(false); } } -
6
Disable the submit button while submitting
Prevent double-submits by disabling the button while the request is in flight. Change the label to give the user feedback that something is happening. Re-enable on success or error.
jsx<button type="submit" disabled={submitting} aria-busy={submitting} > {submitting ? 'Creating account...' : 'Sign up'} </button> {/* Show server-level errors above the button */} {errors.form && ( <p role="alert" style={{ color: 'red' }}>{errors.form}</p> )} -
7
Put it all together
Here is the complete component. Notice how the single
handleChangeserves all inputs, the validation function is separate and testable, and the submit handler covers the full happy path and error path.jsximport { useState } from 'react'; const initialValues = { name: '', email: '', password: '' }; function validate(v) { const e = {}; if (!v.name.trim()) e.name = 'Name is required.'; if (!v.email.trim()) e.email = 'Email is required.'; else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email)) e.email = 'Enter a valid email.'; if (!v.password) e.password = 'Password is required.'; else if (v.password.length < 8) e.password = 'Min 8 characters.'; return e; } export function SignupForm() { const [formData, setFormData] = useState(initialValues); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(false); const handleChange = e => { const { name, value } = e.target; setFormData(p => ({ ...p, [name]: value })); }; const handleBlur = e => { const { name } = e.target; setTouched(p => ({ ...p, [name]: true })); setErrors(p => ({ ...p, [name]: validate(formData)[name] })); }; const handleSubmit = async e => { e.preventDefault(); const errs = validate(formData); if (Object.keys(errs).length) { setErrors(errs); setTouched({ name: true, email: true, password: true }); return; } setSubmitting(true); try { await fakeApi(formData); setFormData(initialValues); setErrors({}); setTouched({}); setSuccess(true); } catch (err) { setErrors({ form: err.message }); } finally { setSubmitting(false); } }; if (success) return <p>Account created!</p>; return ( <form onSubmit={handleSubmit} noValidate> {['name', 'email', 'password'].map(field => ( <div key={field}> <label htmlFor={field}>{field}</label> <input id={field} name={field} type={field === 'password' ? 'password' : field === 'email' ? 'email' : 'text'} value={formData[field]} onChange={handleChange} onBlur={handleBlur} aria-invalid={!!(touched[field] && errors[field])} aria-describedby={errors[field] ? `${field}-err` : undefined} /> {touched[field] && errors[field] && ( <span id={`${field}-err`} role="alert">{errors[field]}</span> )} </div> ))} {errors.form && <p role="alert">{errors.form}</p>} <button type="submit" disabled={submitting}> {submitting ? 'Saving...' : 'Sign up'} </button> </form> ); } -
8
Graduate to react-hook-form when needed
The controlled pattern above is entirely sufficient for forms with up to ~8 fields and simple validation rules. When you need to go further — deeply nested field arrays, complex cross-field validation, file uploads, multi-step forms, or performance-sensitive forms with many fields —
react-hook-formis the standard choice. It uses uncontrolled inputs internally (no re-render on every keystroke), integrates with schema libraries like Zod or Yup, and has a much smaller API surface than alternatives like Formik.jsx// react-hook-form version of the same form import { useForm } from 'react-hook-form'; function SignupForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, reset, } = useForm(); const onSubmit = async (data) => { await fakeApi(data); reset(); }; return ( <form onSubmit={handleSubmit(onSubmit)} noValidate> <input type="text" {...register('name', { required: 'Name is required.' })} aria-invalid={!!errors.name} /> {errors.name && <span role="alert">{errors.name.message}</span>} <input type="email" {...register('email', { required: 'Email is required.', pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Invalid email.' }, })} aria-invalid={!!errors.email} /> {errors.email && <span role="alert">{errors.email.message}</span>} <button type="submit" disabled={isSubmitting}> {isSubmitting ? 'Saving...' : 'Sign up'} </button> </form> ); }
Tips & gotchas
- Always add `noValidate` to the `<form>` element when doing custom validation. Otherwise the browser's built-in validation UI fights with your own error messages.
- The `name` attribute on each input is not optional — it is the key that ties the generic `handleChange` to the correct field in `formData`.
- For checkboxes, use `e.target.checked` instead of `e.target.value`. For multi-select, use `Array.from(e.target.selectedOptions, o => o.value)`.
- Validate on the server too. Client-side validation is a UX improvement, not a security measure — anyone can bypass it.
- When using `react-hook-form` with a UI library (MUI, Chakra), use the `Controller` wrapper for inputs that do not expose a native `ref`.
Wrapping up
A controlled React form is a handful of moving pieces — one state object for values, one for errors, one for touched fields, one for submitting status — but each piece has a single, clear job. Master this pattern and you will handle 90% of forms in real applications. When forms grow beyond that — field arrays, wizard flows, complex dependencies — react-hook-form handles those cases efficiently without the controlled-input overhead.