The React Form Validation Traps I Keep Stepping On
Hand-rolled forms in React look easy until they meet real users. These 5 traps are the ones I keep regressing on: the empty-input case, controlled vs uncontrolled, when to stop hand-rolling, async race conditions, and the accessibility bit nobody catches in review.
By @zurihayes
April 4, 2026
·
Updated May 18, 2026
795 views
3
4.2 (15)
Implement a small controlled form that validates a single text input is non-empty. On submit, show an error list above the button if validation fails. Use hooks, no libraries.
What I want to see
Two pieces of state (inputValue and errors), an onChange that updates the value, an onSubmit that runs validation synchronously and sets errors. The bug in the starter is that errors is referenced in JSX but never declared.
import React, { useState } from 'react';
function SimpleForm() {
const [inputValue, setInputValue] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
// your solution comes here, computing validation and updating state
};
return (
<form onSubmit={handleSubmit}>
<input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
{errors.length > 0 && <div className="error">{errors.join(', ')}</div>}
<button type="submit">Submit</button>
</form>
);
}A teammate's PR converted an uncontrolled form (ref + defaultValue) to a fully controlled form (state + value). The page is now janky on large forms (30+ fields). Walk through when controlled is the right choice, when uncontrolled is, and how you would refactor without going all-in on either.
What I want to see
Named trade-offs: controlled gives instant validation and field interdependencies but re-renders every keystroke; uncontrolled reads on submit and is faster but harder to validate inline. The pragmatic answer: hybrid, with most fields uncontrolled (refs) and only the few that drive UI updates controlled.
// Original: uncontrolled, fast.
function Uncontrolled() {
const ref = React.useRef(null);
return <input ref={ref} defaultValue="" />;
}
// PR change: fully controlled, slow on 30 fields.
function Controlled() {
const [values, setValues] = React.useState({ a: '', b: '', c: '' /* ...30 */ });
return <input value={values.a} onChange={(e) => setValues((s) => ({ ...s, a: e.target.value }))} />;
}When does react-hook-form beat a hand-rolled hooks form, and when is it overkill? My teammate wanted to add it to a 2-field login page. I pushed back. What did I tell them?
What I want to see
The call I usually make: react-hook-form pays off at 5+ fields, when you need field-level validation rules, async resolvers, or want to avoid the Context-of-form-state re-render storm. For a 2-field login the bundle cost is bigger than the typing saved.
// Hand-rolled: 2 fields, fine without a library.
function Login() {
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [errors, setErrors] = React.useState({});
// ...
}
// react-hook-form equivalent
import { useForm } from 'react-hook-form';
function LoginRHF() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('email', { required: 'Email is required' })} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password', { required: true, minLength: 8 })} />
<button type="submit">Login</button>
</form>
);
}I have a username field that checks availability against the server on every change. Users with a fast connection see flicker; users with a slow one see the wrong availability after they keep typing. Walk through the race condition and write a debounced async validator that cannot show a stale result.
What I want to see
The stale-result problem: request A for 'ali' is in flight, user types 'alic', request B fires, but A returns last and overwrites. The fix is either an AbortController or a request-id token that the response compares against the latest issued id.
function UsernameField() {
const [value, setValue] = React.useState('');
const [available, setAvailable] = React.useState(null);
React.useEffect(() => {
// your solution comes here, debounced + race-safe
}, [value]);
return (
<>
<input value={value} onChange={(e) => setValue(e.target.value)} />
{available === true && <span>OK</span>}
{available === false && <span>Taken</span>}
</>
);
}On the last accessibility audit my form failed because errors were not associated with the inputs they described. Screen readers announced the input as Email and never reached the error. Wire up the field so the error is announced as part of the input's accessible description.
What I want to see
aria-invalid on the input when there is an error, an id on the error element, and aria-describedby on the input pointing to that id. Bonus: an aria-live region for the error container so the new text is announced when it appears.
function EmailField({ value, onChange, error }) {
// your solution comes here, wiring aria attributes so the error is announced
return (
<>
<label>
Email
<input type="email" value={value} onChange={onChange} />
</label>
{error && <span>{error}</span>}
</>
);
}