Field-Level Validation in React Forms

Field-Level Validation in React Forms

📌 Problem Statement

In modern web applications, form validation is critical for user experience and data integrity. Many applications suffer from:

  • Inconsistent error messaging and highlighting
  • Scattered validation logic
  • Lack of accessibility (e.g., no focus, screen reader support)
  • Inefficient re-renders when multiple fields validate on the same state

We need a scalable and reusable solution for field-level validation with visual and accessible feedback.


🎯 Objective

  • Display error messages and highlights instantly as the user types
  • Minimize re-renders and isolate state per field
  • Enable custom validations (sync + async)
  • Provide keyboard and screen reader accessibility
  • Make the validation logic reusable


✅ Requirements

  • Accept validation rules per field (e.g., required, pattern, minLength)
  • Show error message when validation fails
  • Apply error styles (red border, shake, focus)
  • Support async validation (e.g., username uniqueness)
  • Easily plug into any reusable <Input /> component
  • Must work with native HTML elements and custom inputs


🗺️ Flow Diagram

Article content
LLD Flow Diagram

📁 File Structure

/hooks
  ├── useFormField.js       → Handles single field state + validation
  └── useFormManager.js     → Manages all fields & submit behavior

/components
  ├── FormField.jsx         → Generic field renderer 
  └── ErrorMessage.jsx      → Accessible error UI        

🔧 Step-by-Step Implementation


1️⃣ useFormField.js

import { useState, useCallback } from "react";

export function useFormField(initialValue = "", validators = []) {
  const [value, setValue] = useState(initialValue);
  const [touched, setTouched] = useState(false);
  const [error, setError] = useState(null);

  const validate = useCallback(() => {
    for (const rule of validators) {
      const result = rule(value);
      if (result) {
        setError(result);
        return false;
      }
    }
    setError(null);
    return true;
  }, [value, validators]);

  const onChange = (e) => {
    setValue(e.target.value);
    if (touched) validate();
  };

  const onBlur = () => {
    setTouched(true);
    validate();
  };

  return {
    value,
    error,
    touched,
    onChange,
    onBlur,
    validate,
    setValue,
  };
}        

2️⃣ useFormManager.js

export function useFormManager(fields) {
  const validateForm = () => {
    let isValid = true;
    for (const field of Object.values(fields)) {
      const valid = field.validate();
      if (!valid && isValid) {
        // focus the first invalid field
        document.getElementById(field.id)?.focus();
      }
      isValid = isValid && valid;
    }
    return isValid;
  };

  return {
    validateForm,
  };
}         

3️⃣ ErrorMessage.jsx

const ErrorMessage = ({ error, id }) => {
  if (!error) return null;
  return (
    <p id={id} className="mt-1 text-sm text-red-600" role="alert">
      {error}
    </p>
  );
};
export default ErrorMessage;         

4️⃣ FormField.jsx

import ErrorMessage from "./ErrorMessage";

const FormField = ({
  label,
  name,
  field,
  type = "text",
  as = "input",
  options = [],
}) => {
  const { value, onChange, onBlur, error } = field;

  const sharedProps = {
    id: name,
    name,
    value,
    onChange,
    onBlur,
    "aria-invalid": !!error,
    "aria-describedby": `${name}-error`,
    className: `mt-1 block w-full p-2 border rounded-md shadow-sm focus:outline-none focus:ring ${
      error ? "border-red-500 focus:ring-red-300" : "border-gray-300 focus:ring-blue-300"
    }`,
  };

  let element;
  if (as === "select") {
    element = (
      <select {...sharedProps}>
        <option value="">-- Select --</option>
        {options.map((opt) => (
          <option key={opt.value} value={opt.value}>
            {opt.label}
          </option>
        ))}
      </select>
    );
  } else if (as === "textarea") {
    element = <textarea rows={4} {...sharedProps} />;
  } else {
    element = <input type={type} {...sharedProps} />;
  }

  return (
    <div className="mb-5">
      {label && (
        <label htmlFor={name} className="block text-sm font-medium text-gray-700">
          {label}
        </label>
      )}
      {element}
      <ErrorMessage error={error} id={`${name}-error`} />
    </div>
  );
};

export default FormField;         

5️⃣ RegistrationForm.jsx

import React from "react";
import FormField from "./components/FormField";
import { required, minLength, isEmail } from "./validators";
import { useFormField } from "./hooks/useFormField";
import { useFormManager } from "./hooks/useFormManager";

export default function RegistrationForm() {
  const username = useFormField("", [required(), minLength(3)]);
  const email = useFormField("", [required(), isEmail()]);
  const role = useFormField("", [required("Please choose a role")]);

  const form = { username, email, role };
  const { validateForm } = useFormManager(form);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!validateForm()) return;
    console.log("Form submitted", {
      username: username.value,
      email: email.value,
      role: role.value,
    });
  };

  return (
    <form onSubmit={handleSubmit} className="max-w-md mx-auto mt-8">
      <FormField label="Username" name="username" field={username} />
      <FormField label="Email" name="email" type="email" field={email} />
      <FormField
        label="Role"
        name="role"
        field={role}
        as="select"
        options={[
          { label: "Admin", value: "admin" },
          { label: "User", value: "user" },
        ]}
      />
      <button
        type="submit"
        className="mt-4 w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
      >
        Submit
      </button>
    </form>
  );
}        

🧠 Design Highlights

  • 📦 Centralized Hook per Field: Each form field is managed using useFormField, encapsulating value, error, and validation logic.
  • Real-time Validation (Post-Touch): Fields validate automatically after user interaction (onBlur), then reactively on each change.
  • 🔐 Controlled Form Submission: A useFormManager hook validates all fields before submission, blocking if any fail.
  • 🛠️ Composable Validators: Simple, reusable functions like required() and isEmail() can be chained per field.
  • Accessible Error Handling: Uses aria-invalid, aria-describedby, and role="alert" for screen reader support.
  • 🧩 Field-Agnostic Component: (<FormField />) Works seamlessly with input, select, textarea via a single reusable component.
  • 🧭 Auto Focus on First Invalid Field: Enhances UX during form submission by guiding users directly to the issue.
  • 🎨 Tailwind-Friendly Styling: Error-based classes and component props integrate well with utility-first CSS like Tailwind.
  • 🔄 Easy to Extend: Supports additional input types (radio, checkbox) and can evolve into a full form engine.


🚀 Further Extensions

  • 🌀 Async Validation Support: Add debounce + promise-based validation (e.g., username availability check via API).
  • 🧾 Schema-Based Validation: Integrate with libraries like Yup or Zod for form-level and field-level validation schemas.
  • 🧠 useForm Hook: Build a custom useForm hook to manage the full form state, dirty flags, touched state, and error collection.
  • 📦 Custom Input Types: Support complex fields like:
  • 💾 Form State Persistence: Auto-save form inputs to localStorage or sessionStorage and restore them on reload.
  • 🔀 Conditional Fields: Show/hide fields dynamically based on values of other fields.
  • 🔒 Secure Fields: Add visibility toggle for password fields with strength meter.
  • 📚 Field Hints & Tooltips: Show contextual help text or tooltip for each field.
  • 🎨 Design System Integration: Bind FormField with your internal UI component system (e.g., Tailwind, Chakra UI, MUI).
  • 🧪 Form Testing Support: Structure your fields with accessible labels + test-friendly data-testid props for clean unit/integration testing.


✅ Conclusion

This approach gives you a clean and centralized solution for handling all form fields with consistent error handling and visual feedback. It’s scalable, readable, and perfect for large codebases or design systems.

To view or add a comment, sign in

Others also viewed

Explore topics