Building Forms in React
- react
- typescript
- forms
- webdev
- frontend
- accessibility
- softwareengineering
In Mutations and Optimistic Updates, I covered how to make a write feel instant: update the cache first, roll back if the server disagrees. Forms are usually where that mutation gets triggered from. They're also where most of the actual complexity in a SaaS product lives. Every "simple" signup form eventually grows validation, async checks, server-side error mapping, and a submit button that needs to behave correctly under retries.
This is part of the React & Next.js Complete Guide series, and it picks up a thread the earlier posts left open. State management patterns and controlled inputs solve the mechanics of typing into a field. They don't solve the harder problem: what does a form actually need to track over its lifetime, and how much of that should you build yourself versus hand to a library.
I want to walk through the decisions that matter for real forms in production: controlled versus uncontrolled inputs, submission lifecycle, accessibility, and the point where rolling your own state management stops paying off. Validation itself, the rules that decide whether a value is acceptable, gets its own treatment in the next article once this one has the surrounding structure in place.
Controlled versus uncontrolled inputs
A controlled input means React owns the value: the input's value prop comes from state, and every keystroke fires onChange to update that state. An uncontrolled input means the DOM owns the value, and you read it out with a ref when you need it, usually at submit time.
function ControlledEmail() {
const [email, setEmail] = useState('');
return (
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
);
}
function UncontrolledEmail() {
const emailRef = useRef<HTMLInputElement>(null);
function handleSubmit() {
const email = emailRef.current?.value ?? '';
// use email here
}
return <input ref={emailRef} type="email" />;
}
Controlled inputs are the default for good reason. If you need to validate as the user types, show a character count, mask input, or disable a submit button based on live field values, you need the value in React state anyway. The re-render cost of a single controlled input isn't worth worrying about; a text field re-rendering on every keystroke is exactly the kind of work React handles cheaply.
The tradeoff shows up at scale, not on a single field. A form with forty inputs, all controlled through one state object, re-renders the entire form component on every keystroke in any field. If that component also renders something expensive, a preview panel, a large table, a chart, you'll feel it. You don't need to abandon controlled inputs to fix that. Isolate each field, or logical group of fields, into its own component so a keystroke in one field only re-renders that field, not its forty siblings. Same "push state down" principle that applies everywhere else in React.
Uncontrolled inputs earn their place in narrower cases: file inputs (which can't be controlled in the traditional sense anyway), forms where you don't need the value until submit, or performance-critical forms where a library manages the DOM directly. React Hook Form, for instance, defaults to uncontrolled inputs under the hood and only re-renders on state you explicitly subscribe to, which is why it holds up well on very large forms.
Modeling submission as a lifecycle, not a boolean
The most common mistake I see in form code is treating submission as a single isSubmitting boolean. That works until you need to answer questions like: did the last submit fail, and why? Is there a submission in flight right now that a second click would race against? Should the button say "Submit," "Submitting...," or "Retry"?
A form's submission has a small, fixed set of states, and modeling them explicitly avoids an entire class of bugs:
type SubmissionState =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'success' }
| { status: 'error'; message: string };
function useFormSubmission<T>(submitFn: (values: T) => Promise<void>) {
const [state, setState] = useState<SubmissionState>({ status: 'idle' });
const requestId = useRef(0);
async function submit(values: T) {
const currentId = ++requestId.current;
setState({ status: 'submitting' });
try {
await submitFn(values);
if (currentId === requestId.current) {
setState({ status: 'success' });
}
} catch (err) {
if (currentId === requestId.current) {
const message = err instanceof Error ? err.message : 'Submission failed';
setState({ status: 'error', message });
}
}
}
return { state, submit };
}
The requestId ref is doing real work here, not just tidiness. If a user double-clicks submit, or submits, edits a field, and submits again before the first request resolves, you don't want a stale response from the first request to overwrite the outcome of the second. Ignoring out-of-order responses is a small addition that prevents a bug that's genuinely hard to reproduce and debug later, because it only shows up under network latency that your local dev environment rarely has.
Disabling the submit button while status === 'submitting' handles the common double-submit case, but don't rely on it exclusively. Disabled buttons can still be triggered by a fast second Enter keypress before React re-renders, by a screen reader interaction, or by a user with a slow connection tapping twice out of habit. The request-id guard is the actual fix; the disabled button is a UX nicety on top of it.
Mapping server errors back to fields
Client-side validation catches obviously malformed input, but the server is the source of truth for anything that requires a database lookup: is this email already registered, is this coupon code still valid, does this slug collide with an existing one. Those errors arrive after submission, and they need to land on the right field, not just as a generic banner.
type FieldErrors = Record<string, string>;
async function handleSubmit(values: SignupValues) {
try {
await api.post('/signup', values);
} catch (err) {
if (isApiValidationError(err)) {
// err.fieldErrors looks like { email: "Email already in use" }
setFieldErrors(err.fieldErrors as FieldErrors);
return;
}
setFormError('Something went wrong. Please try again.');
}
}
This works cleanly if your API returns field-scoped errors in a consistent shape. On the NestJS side, that means your validation pipe and any custom exceptions should serialize to something like { statusCode, message, fieldErrors: { email: '...' } } rather than a single opaque message string. If the backend only ever returns a flat error message, the frontend has no reliable way to attach it to a specific input, and you end up guessing based on string matching, which breaks the moment someone changes the error copy.
A pitfall worth naming directly: don't let server field errors and client field errors live in separate state variables that both render independently. If a client-side check says "email is required" and a server response later says "email already in use," and both are stored separately without one clearing the other, users see two contradictory or duplicated messages under the same field. Keep one error slot per field and have both validation paths write to it.
Accessibility is not optional polish
A form that isn't accessible doesn't work for a meaningful share of users. The fixes here are small and the cost of skipping them is high.
Every input needs a programmatically associated label, either by wrapping the input in a <label> or connecting them with htmlFor and id. A placeholder is not a label; it disappears the moment the user types, and it isn't reliably announced by screen readers.
function EmailField({ value, onChange, error }: EmailFieldProps) {
const errorId = 'email-error';
return (
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={value}
onChange={onChange}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}
aria-describedby links the error message to the input so a screen reader announces it when the user focuses the field, not just when it first appears. role="alert" makes the error announced immediately when it's inserted into the DOM, which matters for errors that appear after an async validation check or a failed submission.
Focus management is the piece that's easiest to forget. When a submit fails validation, move focus to the first invalid field so keyboard and screen reader users land somewhere useful, instead of staying on a button that just changed state underneath them. When a submit succeeds and the form is replaced with a confirmation message, move focus to that message so it's actually read.
When to reach for a form library
Everything above is achievable with useState, useReducer, and plain HTML. For a two or three field form, that's the right amount of code: a library adds a dependency, a bundle size cost, and an API surface to learn, none of which is worth paying for a login form.
The calculus changes with a form that has more than roughly ten to fifteen fields, conditional fields that appear and disappear based on other values, array fields (a list of team members, a list of line items), or a genuine need for per-field re-render isolation. At that point, React Hook Form or a similar library earns its keep: it manages field registration, isolates re-renders per field by default, and gives you a consistent integration point for a schema-based validator, which is exactly what the next article in this series builds on top of.
import { useForm } from 'react-hook-form';
type SignupValues = {
email: string;
password: string;
};
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupValues>();
async function onSubmit(values: SignupValues) {
await api.post('/signup', values);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: true })} type="email" />
{errors.email && <span role="alert">Email is required</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Sign up'}
</button>
</form>
);
}
Notice that even here, the submission lifecycle and accessibility patterns from earlier don't go away. The library manages field state and re-renders; it doesn't manage your server error mapping or your focus handling for you, so those remain your responsibility regardless of which layer holds the field values.
Key takeaways
- Controlled inputs should be the default; reach for uncontrolled inputs and refs only for file inputs, submit-time-only reads, or when a library manages the DOM for you.
- Model submission as a small state machine (idle, submitting, success, error) rather than a single boolean, and guard against stale responses from out-of-order requests.
- Design your API's error shape to include field-scoped errors, and keep one error slot per field so client and server validation don't produce duplicate or contradictory messages.
- Treat labels, `aria-describedby`, and focus management as required parts of a working form, not accessibility polish added at the end.
- Isolate large forms into per-field components so a keystroke in one field doesn't re-render the whole form.
- Reach for a form library once field count, conditional fields, or array fields make manual state management genuinely harder than the dependency cost, not before.
Frequently asked questions
Should I validate on every keystroke, on blur, or only on submit?
Validate on submit for the first pass, then switch to on-blur or on-change for that specific field once it has an error, so the user gets feedback as they fix it without seeing errors flash while they're still typing their first attempt.
Why does my form re-render so much even with controlled inputs?
Usually because all fields share one state object at the top of the form, so every keystroke in any field re-renders the entire tree. Split fields into their own components, or move to a library that isolates subscriptions per field.
Do I need react-hook-form for every form?
No. A login form with two fields is simpler and lighter with plain `useState`. Reach for a library once you have many fields, conditional fields, array fields, or a real performance problem to solve.
How do I show a server-side error like "email already taken" next to the right field?
Return field-scoped errors from your API in a consistent shape, and store form errors in a single `Record<string, string>` keyed by field name so both client and server errors write to the same slot.
What's the right way to handle a form that's still submitting when the user navigates away?
Track the request with an id or an AbortController and ignore the response if the component has unmounted or the request is stale, so a late response can't call `setState` on an unmounted component or overwrite a newer result.
Do I need aria-live regions for form errors?
`role="alert"` on the error element gives you the same assertive announcement behavior as an aria-live region for most cases, and it's simpler to apply per-field than managing a separate live region.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.