Form validation in a React application usually starts on the client. Required fields, email formats, minimum lengths, and simple business rules can be checked before a request is sent to the API. This gives users immediate feedback and avoids unnecessary network requests.
The problem starts when the server applies validation rules that the React application does not know about, or when the server rejects data for reasons that cannot be checked safely on the client.
For example, a registration form may check that an email address looks valid. The API may additionally check whether that email is already registered. A product form may validate the price on the client, while the server checks whether the current user is allowed to change that price.
A reliable form therefore needs two levels of validation:
User input
↓
Client-side validation
↓
API request
↓
Server-side validation
↓
Success or validation errors
↓
Update React form stateThe important part is not choosing between client and server validation. Both have different responsibilities, and the React application needs a clear way to handle errors from each side.
Client Validation and Server Validation Have Different Jobs
Client-side validation exists mainly to improve the user experience.
For example:
Email → required
Password → minimum 8 characters
Name → requiredThese checks can happen immediately while the user is filling out the form.
Server-side validation has a different responsibility. The server is the final authority because it controls the database, business rules, authorization, and data integrity.
A server might reject a request because:
- The email already exists.
- The requested product is no longer available.
- The user does not have permission.
- A value conflicts with another record.
- A business rule was violated.
- The submitted data changed between loading the form and submitting it.
Client validation should therefore improve usability, but it should never be treated as a security boundary.
A Simple React Form
Consider a basic form:
import { useState } from "react";
export default function UserForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
async function handleSubmit(
event: React.FormEvent<HTMLFormElement>
) {
event.preventDefault();
const response = await fetch("/api/users", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
name,
email
})
});
if (response.ok) {
// Handle success
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<input
value={email}
onChange={e => setEmail(e.target.value)}
/>
<button type="submit">Create User</button>
</form>
);
}This works for a simple request, but there is no mechanism for displaying validation errors returned by the API.
That becomes a problem when the server responds with something like:
{
"errors": {
"email": [
"An account with this email already exists."
]
}
}The React form needs to understand that response and connect the error to the correct field.
Keep Field Errors Separate From General Errors
A useful form state usually needs more than the field values.
For example:
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [formError, setFormError] = useState<string | null>(null);These two states represent different types of problems.
Field errors belong to a specific input:
email
↓
"Email is already registered."A general error applies to the whole operation:
"Unable to save the user. Please try again."Keeping these separate makes the UI easier to manage.
Map Server Validation Errors to Form Fields
The API should ideally return a predictable validation structure.
For example:
{
"errors": {
"name": [
"Name is required."
],
"email": [
"Email is already registered."
]
}
}React can store this response directly:
const body = await response.json();
if (!response.ok) {
setErrors(body.errors ?? {});
return;
}Then the email input can display its server-side error:
<input
value={email}
onChange={e => setEmail(e.target.value)}
/>
{errors.email?.map(error => (
<div key={error}>{error}</div>
))}The important design decision is that the API error key must match the field name used by the frontend.
If the API returns:
emailthe frontend should not have to guess whether it means:
emailAddress
Email
userEmailA consistent error contract reduces this kind of mapping logic.
ASP.NET Core Validation Responses
ASP.NET Core commonly uses structured validation responses for model validation.
For example, a request model might look like:
public sealed class CreateUserRequest
{
[Required]
public string Name { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
}If validation fails, the API can return a structured response containing field-level errors.
A React application should treat this response as data rather than relying on a particular server-rendered error message.
The frontend needs to know:
Which field failed?
What message should be displayed?
Is the error related to the whole form?This makes the frontend independent of how the server internally implements validation.
Do Not Duplicate Every Server Rule in React
It is tempting to copy every backend validation rule into the frontend.
For example:
Client:
Email must be unique.
Server:
Email must be unique.The client cannot reliably determine whether an email is unique without asking the server.
Even if the React application sends a request such as:
GET /api/users/check-emailthere is still a race condition.
Another user could register the same email immediately afterward.
The server must therefore enforce the rule when the actual operation occurs.
Client-side checks should focus on rules that can provide useful immediate feedback, while the server remains authoritative.
Avoid Using Server Validation as the Only Validation
The opposite approach also creates a poor user experience.
Suppose the user submits:
Name: empty
Email: invalid
Password: too shortand the application sends the request anyway.
The server may return all three errors, but the user has to wait for a network request before seeing obvious problems.
Client-side validation can catch simple issues immediately.
For example:
function validateForm() {
const errors: Record<string, string[]> = {};
if (!name.trim()) {
errors.name = ["Name is required."];
}
if (!email.trim()) {
errors.email = ["Email is required."];
} else if (!email.includes("@")) {
errors.email = ["Enter a valid email address."];
}
return errors;
}Then:
const clientErrors = validateForm();
if (Object.keys(clientErrors).length > 0) {
setErrors(clientErrors);
return;
}This prevents an unnecessary API request.
Clear Old Errors Before Submitting Again
One common bug occurs when an old server error remains visible after the user changes the input.
Join the conversation! Your thoughts help the community grow.