Forms are often among the most complicated parts of a frontend application. A form must collect input, validate it, communicate with a server, prevent duplicate submissions, and explain errors clearly. Good form design treats these responsibilities as part of the user experience rather than as an afterthought.
Choose an appropriate state model
A controlled input receives its value from React state and updates that state through an event handler.
import { useState } from "react";
function RegistrationForm() {
const [email, setEmail] = useState("");
return (
<label>
Email address
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
);
}
Controlled inputs are useful when other parts of the component need to react to the current value. For simple forms, native browser validation and uncontrolled inputs may be sufficient. Choose the approach that keeps the implementation understandable.
Validate input with clear rules
Validation should be explicit and reusable:
function validate(values) {
const errors = {};
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.length < 12) {
errors.password =
"Password must contain at least 12 characters.";
}
return errors;
}
This is an illustrative validation function, not a complete email or password security policy. Server-side validation remains essential because client-side checks can be bypassed.
Display errors accessibly
An error message should be associated with the field it describes:
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email}
</p>
)}
In a complete form, errors would be state or derived from the current values. The important principle is that an error should be visible and programmatically associated with its input.
Avoid showing every error immediately when a user first opens a form. Many applications show errors after a field has been touched or after the user attempts submission.
Handle asynchronous submission
A form must communicate when a request is in progress and when it fails.
async function submitRegistration(values) {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(values)
});
if (!response.ok) {
throw new Error("Registration failed.");
}
return response.json();
}
The component should track submission state, disable duplicate submissions where appropriate, and catch network or server errors. It should not assume that a completed fetch call means the server accepted the request.
Server validation errors should be mapped back to relevant fields when possible. Unexpected failures should produce a clear general message and allow the user to retry safely.
Prevent stale and duplicate requests
If validation or submission depends on asynchronous operations, consider what happens when the user changes a value while an earlier request is still running. An older response must not overwrite newer state.
Depending on the use case, cancellation with AbortController, request identifiers, or a form library may help.
Know when to use a form library
A small form may need only a few state variables and a validation function. A large form with nested fields, conditional sections, arrays, and complex validation may benefit from a dedicated library.
The goal is not to minimize lines of code at any cost. It is to make validation, errors, and submission behavior predictable.
Conclusion
Reliable React forms combine appropriate state management, understandable validation, accessible error messages, and careful asynchronous handling. A form is successful not merely when it submits data, but when users understand what to enter, what went wrong, and what happens next.