Form Validation in React: Client-Side Basics Guide
Client-side form validation checks user input in the browser before submission, providing immediate feedback and improving user experience. In React, validation is straightforward using controlled components and state: store form values and error messages, validate on input change, and disable submission until all fields pass validation. This article shows how to build a production-ready registration form with real-time validation and clear error messaging.
Key Takeaways
- Client-side validation provides immediate, user-friendly feedback without a server round-trip
- Store form values in one state object and error messages in another for clean separation of concerns
- Validate on the
onChangeevent to catch errors as the user types (real-time validation) - Always validate on the server side as well; client-side validation can be bypassed
- Disable the submit button until all validation errors are cleared to prevent accidental submission of invalid data
- For complex forms with many interdependent rules, consider libraries like Formik or React Hook Form
Prerequisites
Before reading this article, understand:
- Controlled components and two-way data binding with
useState - How HTML form inputs work (text, email, password types)
- Basic JavaScript regex (regular expressions) for pattern matching
- Event handling with
onChangeandonSubmit
Four Types of Validation Rules
The most common validation scenarios are:
- Presence validation — Ensure a field is not empty
- Length validation — Check minimum or maximum character count
- Format validation — Match a pattern (email, phone, ZIP code) using regex
- Inclusion/Exclusion validation — Ensure the value is in or excluded from a set
Building a Registration Form with Validation
Here's a complete, working registration form that validates three fields in real time.
Step 1: Set Up State for Values and Errors
import React, { useState } from 'react';
function RegistrationForm() {
const [values, setValues] = useState({
username: '',
email: '',
password: ''
});
const [errors, setErrors] = useState({});
// ... handlers and JSX to come
}
Store form values in one state object and validation errors in another. This separation makes it easy to reason about each concern independently.
Step 2: Implement Validation Logic
Create a function that validates a single field and returns an error message (or undefined if valid):
const validateField = (name, value) => {
switch (name) {
case 'username':
if (!value.trim()) {
return 'Username is required';
}
if (value.length < 3) {
return 'Username must be at least 3 characters';
}
return undefined;
case 'email':
if (!value.trim()) {
return 'Email is required';
}
if (!/\S+@\S+\.\S+/.test(value)) {
return 'Email format is invalid ([email protected])';
}
return undefined;
case 'password':
if (!value) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
if (!/[A-Z]/.test(value)) {
return 'Password must include at least one uppercase letter';
}
return undefined;
default:
return undefined;
}
};
The regex pattern /\S+@\S+\.\S+/ validates basic email format: one or more non-whitespace characters, an @ symbol, a domain name, a dot, and a top-level domain.
Step 3: Handle Input Changes with Real-Time Validation
The handleChange function updates both values and errors on every keystroke:
const handleChange = (e) => {
const { name, value } = e.target;
// Update form values
setValues(prevValues => ({
...prevValues,
[name]: value
}));
// Validate the field and update errors
const error = validateField(name, value);
setErrors(prevErrors => ({
...prevErrors,
[name]: error
}));
};
Real-time validation provides immediate feedback, helping users correct mistakes as they type.
Step 4: Handle Form Submission
const handleSubmit = (e) => {
e.preventDefault();
// Check if any errors exist
const hasErrors = Object.values(errors).some(error => error !== undefined);
if (!hasErrors && values.username && values.email && values.password) {
console.log('Form is valid. Submitting:', values);
alert('Registration successful!');
// Here you would typically send data to the server
} else {
alert('Please fix all errors before submitting');
}
};
Before submission, verify that the errors object contains no errors and all required fields have values.
Step 5: Render the Form with Error Display
const isSubmitDisabled = Object.values(errors).some(error => error !== undefined) ||
!values.username || !values.email || !values.password;
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">Username:</label>
<input
id="username"
type="text"
name="username"
value={values.username}
onChange={handleChange}
aria-invalid={errors.username ? 'true' : 'false'}
/>
{errors.username && (
<p style={{ color: '#d32f2f', fontSize: '0.875rem' }}>
{errors.username}
</p>
)}
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
name="email"
value={values.email}
onChange={handleChange}
aria-invalid={errors.email ? 'true' : 'false'}
/>
{errors.email && (
<p style={{ color: '#d32f2f', fontSize: '0.875rem' }}>
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
name="password"
value={values.password}
onChange={handleChange}
aria-invalid={errors.password ? 'true' : 'false'}
/>
{errors.password && (
<p style={{ color: '#d32f2f', fontSize: '0.875rem' }}>
{errors.password}
</p>
)}
</div>
<button type="submit" disabled={isSubmitDisabled}>
Register
</button>
</form>
);
Key details:
- The submit button is disabled until all fields are valid and filled
- Error messages are only shown if an error exists for that field
aria-invalidattributes improve accessibility for screen readers- Each input has a matching
<label>for accessibility
Complete Working Component
Here's the entire component in one block:
import React, { useState } from 'react';
function RegistrationForm() {
const [values, setValues] = useState({
username: '',
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const validateField = (name, value) => {
switch (name) {
case 'username':
if (!value.trim()) {
return 'Username is required';
}
if (value.length < 3) {
return 'Username must be at least 3 characters';
}
return undefined;
case 'email':
if (!value.trim()) {
return 'Email is required';
}
if (!/\S+@\S+\.\S+/.test(value)) {
return 'Email format is invalid';
}
return undefined;
case 'password':
if (!value) {
return 'Password is required';
}
if (value.length < 6) {
return 'Password must be at least 6 characters';
}
if (!/[A-Z]/.test(value)) {
return 'Password must include at least one uppercase letter';
}
return undefined;
default:
return undefined;
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
const error = validateField(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
};
const handleSubmit = (e) => {
e.preventDefault();
const hasErrors = Object.values(errors).some(error => error !== undefined);
if (!hasErrors) {
alert('Registration successful!');
console.log('Submitting:', values);
}
};
const isSubmitDisabled = Object.values(errors).some(error => error) ||
!values.username || !values.email || !values.password;
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">Username:</label>
<input
id="username"
name="username"
value={values.username}
onChange={handleChange}
/>
{errors.username && <p style={{ color: 'red' }}>{errors.username}</p>}
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
name="email"
value={values.email}
onChange={handleChange}
/>
{errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
</div>
<div>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
name="password"
value={values.password}
onChange={handleChange}
/>
{errors.password && <p style={{ color: 'red' }}>{errors.password}</p>}
</div>
<button type="submit" disabled={isSubmitDisabled}>
Register
</button>
</form>
);
}
export default RegistrationForm;
Best Practices for Form Validation
1. Validate on Multiple Events
Validate on onChange for real-time feedback, and optionally on onBlur when the user leaves a field:
const handleBlur = (e) => {
const { name, value } = e.target;
const error = validateField(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
};
// In input:
<input
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
2. Provide Clear, Actionable Error Messages
Bad: "Invalid" Good: "Email format is invalid. Example: [email protected]"
3. Never Rely on Client-Side Validation Alone
Client-side validation can be bypassed by disabling JavaScript or intercepting requests. Always validate on the server:
// After form submission, send to server:
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values)
});
if (!response.ok) {
// Handle server-side validation errors
const serverErrors = await response.json();
setErrors(serverErrors);
}
4. Use Form Libraries for Complex Forms
For forms with many fields, conditional validation, or nested data, use a library:
- Formik — Popular, feature-rich, with built-in error handling
- React Hook Form — Lightweight, performant, integrates well with
useState - Zod or Yup — Schema validation libraries that pair well with form libraries
Frequently Asked Questions
Should I validate before or after checking the form is dirty?
You can validate on change (as shown here) or wait until the user has touched each field. For real-time feedback, validate on change. For less aggressive validation, track which fields are "touched" and only show errors for those fields.
What regex pattern validates an email?
The pattern /\S+@\S+\.\S+/ is simple and catches most cases. For strict RFC 5322 compliance, use a more complex pattern, but in practice, the simple pattern works for most forms. For the most reliable validation, verify the email on the server by sending a confirmation link.
How do I validate that two fields match, like password and confirm password?
Store both in state, then validate the confirm password field against the password field:
case 'confirmPassword':
if (value !== values.password) {
return 'Passwords do not match';
}
return undefined;
Can I show a success message after validation?
Yes. Add a success state and display it after successful submission:
const [submitSuccess, setSubmitSuccess] = useState(false);
const handleSubmit = (e) => {
if (isValid) {
setSubmitSuccess(true);
setTimeout(() => setSubmitSuccess(false), 3000); // Hide after 3s
}
};
Is client-side validation necessary if I validate on the server?
Yes. Client-side validation improves user experience by providing instant feedback without a server round-trip. Use it alongside server-side validation for the best experience.