Form Validation and Error Display in React

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
Note: The most comfortable validation UX for most forms is a hybrid: validate on submit to show all errors at once, then switch to on change for fields that already have errors โ€” so the error disappears as soon as the user corrects it. This is exactly the pattern used in the Login and Register forms in Lesson 2: errors appear on submit and clear as the user types.
Tip: Server-side validation errors from the Express API come in a predictable format if you built your Express errorHandler correctly โ€” an array of { 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.
Warning: Never trust client-side validation as a security measure. A determined user can disable JavaScript, use browser dev tools to remove the required attribute, or call your API directly. Client-side validation is purely a convenience for the user. Your Express API must always re-validate every input server-side before touching the database โ€” regardless of what the React client sends.

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('');

🧠 Test Yourself

Your Express API returns { errors: [{ field: 'title', message: 'Title is required' }, { field: 'body', message: 'Body is required' }] }. How should you display these in React?