Form validation is a common part of React development. A registration form may need to validate an email address, a password, and a user's name. A checkout form may need to validate billing details, shipping information, and payment fields. In larger applications, forms must also handle server errors, loading states, and errors that appear after a user submits the form.
One problem developers often face is duplicated state. For example, a form might store the email address, a separate Boolean indicating whether the email is valid, another Boolean indicating whether an error exists, and a message describing the same error.
These values can easily get out of sync. A user changes the email address, but the validation state still reflects the previous value. An error message remains visible after the input has been corrected. Or the form allows submission because one state variable was updated while another was not.
A better approach is to store the information the application actually needs and derive the rest from it.
This article explains how to manage React form errors without unnecessary state, how to handle client-side and server-side validation, and how to build a form that remains easy to maintain as requirements grow.
Why Duplicated Form State Causes Problems
Consider a simple email form with these state variables:
const [email, setEmail] = useState("");
const [isEmailValid, setIsEmailValid] = useState(false);
const [hasEmailError, setHasEmailError] = useState(false);
const [emailError, setEmailError] = useState("");All four variables describe closely related information.
When the user changes the email address, the application must update the email value and keep the other variables consistent. When the user submits the form, it may need to validate the email again and update the error state.
This creates several possible inconsistencies:
The email value changes, but the validity flag remains unchanged.
The error message is cleared, but the error flag remains true.
The validity flag says the email is valid, while the error message still says it is invalid.
Different event handlers apply different validation rules.
React state updates are not inherently unreliable. The problem is that the application is maintaining several values that can be calculated from the same underlying information.
If the email's validity depends only on the current email value, calculate that validity from the value instead of storing it separately.
Keep the Source of Truth in One Place
A useful rule for React forms is to distinguish between source state and derived values.
Source state contains information the application needs to remember, such as the current field values, whether a field has been touched, and whether a request is in progress.
Derived values can be calculated from that state. Examples include whether the email is valid, whether the form can be submitted, and which validation message should be displayed.
Consider this example:
import { useState } from "react";
function EmailForm() {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const emailError =
email.trim() === ""
? "Email is required."
: !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
? "Enter a valid email address."
: "";
const isEmailValid = emailError === "";
function handleSubmit(
event: React.FormEvent<HTMLFormElement>
) {
event.preventDefault();
setSubmitted(true);
if (!isEmailValid) {
return;
}
console.log("Submitting email:", email);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
aria-invalid={submitted && !isEmailValid}
aria-describedby={
submitted && !isEmailValid
? "email-error"
: undefined
}
/>
{submitted && emailError && (
<p id="email-error" role="alert">
{emailError}
</p>
)}
<button type="submit">Continue</button>
</form>
);
}
export default EmailForm;The email value is the source of truth. The error message and validity are derived from it.
There is no need to update an error string every time the email changes. React recalculates the error during rendering, so the message always reflects the current value.
The submitted state has a different purpose: it records whether the user has attempted to submit the form. It controls when the error should become visible.
The regular expression here is a basic client-side check, not a complete implementation of every valid email format. For a production application, use validation rules appropriate to the backend and the application's requirements.
Track Whether a Field Has Been Touched
Showing validation errors immediately can make a form difficult to use. For example, a user may see an error as soon as an empty form appears, even though they have not interacted with it.
A common solution is to track whether the user has touched a field or attempted to submit the form.
These are meaningful pieces of state because they describe user interaction. The error itself can still be derived from the field value.
import { useState } from "react";
function EmailForm() {
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const [submitted, setSubmitted] = useState(false);
const emailError =
email.trim() === ""
? "Email is required."
: !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
? "Enter a valid email address."
: "";
const showEmailError =
(touched || submitted) && emailError !== "";
function handleSubmit(
event: React.FormEvent<HTMLFormElement>
) {
event.preventDefault();
setSubmitted(true);
if (emailError !== "") {
return;
}
console.log("Form submitted:", email);
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={showEmailError}
aria-describedby={
showEmailError ? "email-error" : undefined
}
/>
{showEmailError && (
<p id="email-error" role="alert">
{emailError}
</p>
)}
<button type="submit">Submit</button>
</form>
);
}The noValidate attribute disables the browser's automatic form validation UI so this example can control when its own validation messages appear. The input still has its type="email" attribute, which helps browsers and assistive technologies understand the field.
The form displays an error after the user leaves the field or attempts submission. If the user corrects the email, the derived error becomes empty and the message disappears automatically.
For forms with multiple fields, use a touched-state object or another suitable structure rather than creating unrelated Boolean variables for every possible condition.
Manage Multiple Fields Without Repeating Validation Logic
A registration form may contain a name, email, and password. Managing each field independently can make the component unnecessarily long.
You can store the field values in one object while keeping validation separate.
import { useState } from "react";
type RegistrationValues = {
name: string;
email: string;
password: string;
};
type RegistrationErrors = Partial<
Record<keyof RegistrationValues, string>
>;
function validateRegistration(
values: RegistrationValues
): RegistrationErrors {
const errors: RegistrationErrors = {};
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.length < 8) {
errors.password =
"Password must contain at least eight characters.";
}
return errors;
}
function RegistrationForm() {
const [values, setValues] = useState<RegistrationValues>({
name: "",
email: "",
password: ""
});
const [submitted, setSubmitted] = useState(false);
const errors = validateRegistration(values);
function updateField(
field: keyof RegistrationValues,
value: string
) {
setValues((current) => ({
...current,
[field]: value
}));
}
function handleSubmit(
event: React.FormEvent<HTMLFormElement>
) {
event.preventDefault();
setSubmitted(true);
const currentErrors = validateRegistration(values);
if (Object.keys(currentErrors).length > 0) {
return;
}
console.log("Registration is ready to submit.");
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
value={values.name}
onChange={(event) =>
updateField("name", event.target.value)
}
aria-invalid={submitted && Boolean(errors.name)}
/>
{submitted && errors.name && (
<p role="alert">{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={values.email}
onChange={(event) =>
updateField("email", event.target.value)
}
aria-invalid={submitted && Boolean(errors.email)}
/>
{submitted && errors.email && (
<p role="alert">{errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={values.password}
onChange={(event) =>
updateField("password", event.target.value)
}
aria-invalid={
submitted && Boolean(errors.password)
}
/>
{submitted && errors.password && (
<p role="alert">{errors.password}</p>
)}
</div>
<button type="submit">Create account</button>
</form>
);
}
export default RegistrationForm;This approach keeps the field values together and moves validation rules into a separate function.
Notice that errors is calculated from values; it is not maintained as a second state object that must be updated after every keystroke.
In a larger form, you may choose to validate only fields that have been touched, validate on blur, or validate the entire form on submission. The important point is to keep the rules in one place so the behavior remains consistent.
The example validates the password length only. Real password requirements should be defined according to the application's security needs, and password strength checks should not replace secure server-side account handling.
Handle Server-Side Validation Errors Separately
Client-side validation improves the user experience, but it cannot replace server-side validation.
A client can bypass browser checks, modify requests, or send data directly to an API. The backend must validate the request independently.
Suppose an API rejects an email because the address is already registered. The email may pass the frontend's format check, but the server still rejects the request.
That error is different from a client-side validation error. It depends on the API response, so it may need to be stored in state.
A simple approach is to keep a server error message separate from the derived client-side errors.
const [serverError, setServerError] = useState<string | null>(
null
);
const [isSubmitting, setIsSubmitting] = useState(false);
async function submitRegistration(
values: RegistrationValues
) {
setServerError(null);
setIsSubmitting(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(values)
});
if (response.status === 409) {
setServerError(
"An account with this email already exists."
);
return;
}
if (!response.ok) {
setServerError(
"Registration could not be completed. Please try again."
);
return;
}
// Handle the successful response here.
} catch {
setServerError(
"Unable to connect to the server. Check your connection and try again."
);
} finally {
setIsSubmitting(false);
}
}This function can be called from the form's submit handler after client-side validation succeeds.
The serverError state is justified because the message comes from an external operation. It cannot be calculated from the current form values alone.
Likewise, isSubmitting represents the status of an asynchronous request, so it is appropriate to store it in state.
For production code, map known API status codes and validation response formats to user-friendly messages. Avoid displaying raw server exceptions or internal diagnostic details to users.
Also consider whether server errors should be cleared when the user edits a field. For example, an email conflict message may no longer be relevant after the email changes. That behavior should be explicit rather than handled by storing duplicate versions of the same validation state.
Use a Consistent Pattern for Error Display
As forms grow, consistent error rendering becomes important. Each field should have a label, an input, an error message when needed, and an accessible relationship between the input and its message.
For example:
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
aria-invalid={showEmailError}
aria-describedby={
showEmailError ? "email-error" : undefined
}
/>
{showEmailError && (
<p id="email-error" role="alert">
Enter a valid email address.
</p>
)}
</div>The aria-invalid attribute communicates that the field currently contains an invalid value. The aria-describedby attribute associates the input with its error message, helping assistive technology users understand the problem.
Use stable, unique IDs when rendering repeated fields. Avoid showing the same error through several separate mechanisms unless each serves a clear purpose.
For forms with many fields, a reusable field component can reduce repeated markup. Keep the validation logic separate from the visual component so rules can be tested without rendering the entire form.
Avoid Common Form State Mistakes
Storing values that can be calculated: If a value is always derived from the current form values, calculate it during rendering or in a validation function instead of maintaining a second state variable.
Updating state from stale values: When a new value depends on previous state, use the functional state updater, as shown in the updateField function.
Validating only in the browser: Always validate input on the server before accepting or processing it.
Keeping old server errors after input changes: Decide when a server error becomes irrelevant and clear it when appropriate.
Mixing request status with validation status: Client validation errors, server validation errors, and request failures have different causes. Represent them clearly rather than combining everything into one Boolean.
Using effects to synchronize derived state: Avoid using useEffect just to copy a calculated validation result into another state variable. Derive the result directly unless there is a specific external synchronization requirement.
Conclusion
React forms become easier to maintain when you keep source state separate from values that can be derived from it.
Store the user's input and meaningful interaction or request state. Calculate validation errors from the current values, and keep server errors separate because they come from an external operation.
For larger forms, centralize validation rules, use TypeScript to describe the form data, and make error messages accessible. Always repeat important validation on the server.
This approach reduces duplicated state, prevents inconsistent error messages, and makes it easier to add new fields and validation rules without turning a form component into a collection of interdependent state updates.
Join the conversation! Your thoughts help the community grow.