Skip to main content

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 onChange event 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 onChange and onSubmit

Four Types of Validation Rules

The most common validation scenarios are:

  1. Presence validation — Ensure a field is not empty
  2. Length validation — Check minimum or maximum character count
  3. Format validation — Match a pattern (email, phone, ZIP code) using regex
  4. 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-invalid attributes 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.

Further Reading