Client-side validation improves the user experience by catching errors before an API call is made โ faster feedback, no network round-trip for obvious mistakes. But client-side validation alone is never sufficient โ the Express API validates everything server-side too (using express-validator, as built in Chapter 8). The skill in this lesson is combining both: validate immediately on the client for fast feedback, and gracefully display server-side errors when they arrive, presenting them next to the relevant form fields just like client-side errors. Together they produce the polished, professional form experience MERN Blog users expect.
Validation Timing โ Three Options
| Timing | When It Fires | Pro | Con |
|---|---|---|---|
| On submit | User clicks the submit button | No interruption while typing | User discovers all errors at once after filling the form |
| On blur | User leaves the field | Validates each field when done | More complex state management |
| On change | Every keystroke | Immediate feedback | Can feel aggressive before user finishes typing |
{ field, message } objects. In React, convert this array into an object keyed by field name: Object.fromEntries(errors.map(e => [e.field, e.message])). Then display them the same way as client-side errors โ the user does not need to know whether an error came from the client or the server.Complete Validation System
// src/utils/validators.js โ pure validation functions (no React)
export const validators = {
required: (label) => (value) =>
!value || String(value).trim() === '' ? `${label} is required` : '',
minLength: (label, min) => (value) =>
value && value.length < min ? `${label} must be at least ${min} characters` : '',
maxLength: (label, max) => (value) =>
value && value.length > max ? `${label} cannot exceed ${max} characters` : '',
email: () => (value) =>
value && !/^\S+@\S+\.\S+$/.test(value) ? 'Enter a valid email address' : '',
url: (label) => (value) =>
value && !/^https?:\/\/.+/.test(value) ? `${label} must be a valid URL` : '',
match: (label, compareValue) => (value) =>
value !== compareValue ? `${label} do not match` : '',
};
// โโ Run a set of rules against a value โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Returns the first failing error message, or '' if all pass
export const runRules = (value, rules) => {
for (const rule of rules) {
const error = rule(value);
if (error) return error;
}
return '';
};
// โโ Validate the entire form object โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// schema: { fieldName: [rule1, rule2, ...] }
export const validateForm = (formData, schema) => {
const errors = {};
for (const [field, rules] of Object.entries(schema)) {
const error = runRules(formData[field], rules);
if (error) errors[field] = error;
}
return errors; // { fieldName: 'error message' } โ empty object if all valid
};
Using the Validation Utilities
import { validators, validateForm } from '@/utils/validators';
// Define schema once โ reuse across client and (conceptually) server
const loginSchema = {
email: [validators.required('Email'), validators.email()],
password: [validators.required('Password'), validators.minLength('Password', 8)],
};
const registerSchema = {
name: [validators.required('Name'), validators.minLength('Name', 2)],
email: [validators.required('Email'), validators.email()],
password: [validators.required('Password'), validators.minLength('Password', 8)],
confirmPassword: [validators.required('Confirm password')],
// Note: match rule added dynamically since it needs formData.password
};
// In the form component:
const handleSubmit = async (e) => {
e.preventDefault();
const errs = validateForm(formData, loginSchema);
if (Object.keys(errs).length) { setErrors(errs); return; }
// ... submit to API
};
Converting Server Errors to Field Errors
// Express returns validation errors in this shape (from Chapter 8):
// { success: false, message: "Validation failed", errors: [{ field, message }] }
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setErrors({});
setApiError('');
try {
await axios.post('/api/posts', formData);
navigate('/dashboard');
} catch (err) {
const res = err.response?.data;
if (res?.errors && Array.isArray(res.errors)) {
// Express field-level validation errors โ convert to { field: message }
const fieldErrors = Object.fromEntries(
res.errors.map(e => [e.field, e.message])
);
setErrors(fieldErrors); // display next to the relevant inputs
} else {
// General API error โ display at top of form
setApiError(res?.message || 'Something went wrong. Please try again.');
}
} finally {
setLoading(false);
}
};
On-Blur Validation for Individual Fields
// Validate a field when the user leaves it (onBlur)
function EmailInput({ value, onChange }) {
const [touched, setTouched] = useState(false);
const [error, setError] = useState('');
const handleBlur = () => {
setTouched(true);
if (!value) setError('Email is required');
else if (!/\S+@\S+\.\S+/.test(value)) setError('Enter a valid email');
else setError('');
};
const handleChange = (e) => {
onChange(e);
if (touched) {
// Re-validate on change only after first blur
if (e.target.value) setError('');
}
};
return (
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={value}
onChange={handleChange}
onBlur={handleBlur}
className={error ? 'input input--error' : 'input'}
/>
{error && <p className="field-error">{error}</p>}
</div>
);
}
Common Mistakes
Mistake 1 โ Validating only on the client and not the server
โ Wrong โ Express route with no server-side validation:
// Express route
router.post('/api/posts', protect, createPost); // no validation middleware
// A user calling the API directly can bypass all client-side rules
โ Correct โ always validate server-side regardless of client validation:
router.post('/api/posts', protect, ...createPostRules, validate, createPost); // โ
Mistake 2 โ Displaying all server errors in a single banner instead of field errors
โ Wrong โ showing “title: Title is required, body: Body is required” as one generic message:
setApiError(res.errors.map(e => e.message).join(', '));
// User must hunt for which field each error refers to
โ Correct โ convert to field-level errors so they appear next to each input:
setErrors(Object.fromEntries(res.errors.map(e => [e.field, e.message]))); // โ
Mistake 3 โ Not resetting errors before each submit
โ Wrong โ errors from a previous failed submission persist after fixing and resubmitting:
const handleSubmit = async (e) => {
e.preventDefault();
// No setErrors({}) โ old errors may linger
};
โ Correct โ clear errors at the start of each submission:
setErrors({});
setApiError('');
// Then validate and potentially re-set errors โ
Quick Reference
| Task | Code |
|---|---|
| Validate on submit | Call validate() before API call, setErrors() if errors exist |
| Clear field error on change | if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' })) |
| Display field error | {errors.title && <p className="field-error">{errors.title}</p>} |
| Convert server errors | Object.fromEntries(res.errors.map(e => [e.field, e.message])) |
| Error class on input | className={errors.field ? 'input input--error' : 'input'} |
| Reset before submit | setErrors({}); setApiError(''); |