Back to Blog
Form Validation: Back to Basics, Beyond the Framework
10 min readJun 30, 20261 views

Form Validation: Back to Basics, Beyond the Framework

We've all been there: a new form appears, and the first instinct is to pull in a heavy validation library. But what if we treated validation as core domain logic, independent of our UI framework? It turns out, that's often a simpler, more robust path.

FrontendSoftware DesignReactTypeScriptWeb Development
Share

by Sunil Band

The Over-Engineering Trap of Form Validation

Every React developer eventually faces the dreaded form. It starts simple: a couple of inputs, a submit button. Then come the requirements: "the email needs to be valid," "password must be at least 8 characters," "confirm password must match." Before you know it, you've pulled in react-hook-form or Formik, added Zod or Yup, and suddenly, a simple login form has a bundle size that feels like overkill.

Don't get me wrong, these libraries solve real problems, especially for complex, dynamic forms with many interdependent fields. But for the vast majority of forms, especially those common ones like login, registration, or a simple contact form, I've started questioning if we're reaching for a sledgehammer when a tack hammer would do.

The core issue is often that we conflate UI state management for forms with domain-specific validation logic. While they often appear together, they're distinct concerns. When you couple your core business rules (like "an email must contain an @ symbol and a domain") directly to a UI framework or a third-party validation library, you create unnecessary dependencies and make testing harder.

Why Decouple Validation?

Think about it: your validation rules are fundamental to your application's data integrity. They define what constitutes valid business data. This logic often needs to be consistent across your frontend, backend, and potentially other services. If you bake it deeply into a React component's onSubmit handler or a Zod schema tied to a specific form structure, you're making it harder to reuse, harder to test in isolation, and ultimately, harder to maintain.

Decoupling means treating validation as a pure function or a service that takes raw data and returns a result, often a list of errors or null if valid. This approach has several benefits:

  1. Reusability: The same validateUserRegistration function can be used on the client before submission, and on the server when the request hits the API, ensuring consistency.
  2. Testability: Pure functions are a dream to test. No need to render components or mock Formik contexts. Just pass inputs and assert outputs.
  3. Framework Agnostic: Your validation logic isn't tied to React, Vue, or even the web. It's just JavaScript/TypeScript.
  4. Clarity: It separates "how the UI behaves" from "what constitutes valid data."

The "Vanilla" Approach: Pure Functions for Validation

Let's build a simple registration form to illustrate. We'll manage form state with useState (or useReducer for more complex forms, but useState is fine here), and validation with plain TypeScript functions.

First, define your error types and the structure of your validation result. I like to return an object mapping field names to an array of error messages. This makes it easy to display all errors for a field.

typescript
type FieldErrors = { [key: string]: string[] };

// Our core validation function
function validateRegistration(data: { email: string; password: string; confirmPassword: string }): FieldErrors | null {
  const errors: FieldErrors = {};

  // Email validation
  if (!data.email) {
    errors.email = errors.email || [];
    errors.email.push('Email is required.');
  } else if (!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(data.email)) { // Simple regex for demonstration
    errors.email = errors.email || [];
    errors.email.push('Email is not valid.');
  }

  // Password validation
  if (!data.password) {
    errors.password = errors.password || [];
    errors.password.push('Password is required.');
  } else if (data.password.length < 8) {
    errors.password = errors.password || [];
    errors.password.push('Password must be at least 8 characters long.');
  }

  // Confirm password validation
  if (!data.confirmPassword) {
    errors.confirmPassword = errors.confirmPassword || [];
    errors.confirmPassword.push('Confirm password is required.');
  } else if (data.password !== data.confirmPassword) {
    errors.confirmPassword = errors.confirmPassword || [];
    errors.confirmPassword.push('Passwords do not match.');
  }

  return Object.keys(errors).length > 0 ? errors : null;
}

Notice how validateRegistration is a pure function. It takes an object and returns an object of errors or null. No React, no useEffect, no context, just plain logic. This function could live in a src/domain/validation.ts file.

Now, let's integrate this into a React component:

```typescript react
import React, { useState, FormEvent } from 'react';
// Assuming validateRegistration is imported from './domain/validation'
// import { validateRegistration } from './domain/validation';
type FieldErrors = { [key: string]: string[] };
// Dummy validateRegistration for standalone example
function validateRegistration(data: { email: string; password: string; confirmPassword: string }): FieldErrors | null {
const errors: FieldErrors = {};
if (!data.email) errors.email = ['Email is required.'];
else if (!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(data.email)) errors.email = ['Email is not valid.'];
if (!data.password) errors.password = ['Password is required.'];
else if (data.password.length < 8) errors.password = ['Password must be at least 8 characters long.'];
if (!data.confirmPassword) errors.confirmPassword = ['Confirm password is required.'];
else if (data.password !== data.confirmPassword) errors.confirmPassword = ['Passwords do not match.'];
return Object.keys(errors).length > 0 ? errors : null;
}
function RegistrationForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
});
const [errors, setErrors] = useState<FieldErrors | null>(null);
const [isSubmitted, setIsSubmitted] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error for the current field as user types
if (errors && errors[name]) {
const newErrors = { ...errors };
delete newErrors[name];
if (Object.keys(newErrors).length === 0) {
setErrors(null);
} else {
setErrors(newErrors);
}
}
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setIsSubmitted(true);
const validationErrors = validateRegistration(formData);
if (validationErrors) {
setErrors(validationErrors);
console.log('Validation errors:', validationErrors);
} else {
setErrors(null);
console.log('Form data is valid:', formData);
// Simulate API call
alert('Registration successful!');
}
};
// Live validation on blur or change
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
// Only validate on blur if form has been submitted, or if field already has an error
if (isSubmitted || (errors && errors[e.target.name])) {
const currentFieldData = { ...formData, [e.target.name]: e.target.value };
const newErrors = validateRegistration(currentFieldData);
setErrors(prevErrors => {
const updatedErrors = { ...prevErrors, ...newErrors };
if (!newErrors || !newErrors[e.target.name]) {
delete updatedErrors[e.target.name];
}
return Object.keys(updatedErrors).length > 0 ? updatedErrors : null;
});
}
};
return (
<form onSubmit={handleSubmit} className="p-4 max-w-md mx-auto bg-white shadow-md rounded-lg">
<div className="mb-4">
<label htmlFor="email" className="block text-gray-700 text-sm font-bold mb-2">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
className={shadow appearance-none border ${errors?.email ? 'border-red-500' : ''} rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline}
/>
{errors?.email && errors.email.map((msg, i) => (
<p key={i} className="text-red-500 text-xs italic mt-1">{msg}</p>
))}
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-gray-700 text-sm font-bold mb-2">Password:</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
onBlur={handleBlur}
className={shadow appearance-none border ${errors?.password ? 'border-red-500' : ''} rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline}
/>
{errors?.password && errors.password.map((msg, i) => (
<p key={i} className="text-red-500 text-xs italic mt-1">{msg}</p>
))}
</div>
<div className="mb-6">
<label htmlFor="confirmPassword" className="block text-gray-700 text-sm font-bold mb-2">Confirm Password:</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
onBlur={handleBlur}
className={shadow appearance-none border ${errors?.confirmPassword ? 'border-red-500' : ''} rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline}
/>
{errors?.confirmPassword && errors.confirmPassword.map((msg, i) => (
<p key={i} className="text-red-500 text-xs italic mt-1">{msg}</p>
))}
</div>
<div className="flex items-center justify-between">
<button
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
>
Register
</button>
</div>
</form>
);
}
export default RegistrationForm;

plaintext
plaintext

### Explanation of the React Component

1.  **State Management:** `formData` holds the current input values, and `errors` holds any validation messages. `isSubmitted` is a flag to help with UX for when to show errors (e.g., only after first submission attempt, or on blur if submitted).
2.  **`handleChange`:** Updates `formData` and *optionally* clears errors for the field being typed in. This provides immediate feedback if a user corrects an error.
3.  **`handleBlur`:** This is where you can implement **real-time validation on interaction**. If the form has already been submitted or if the field currently has an error, we re-run validation on the specific field's data (or all data, depending on your rule granularity) to provide live feedback. This is a common pattern for good UX.
4.  **`handleSubmit`:** This is the primary entry point for validation. It calls our pure `validateRegistration` function. If errors exist, they are stored in state and displayed. Otherwise, the form is considered valid and the submission logic proceeds.
5.  **Error Display:** Each input checks `errors?.fieldName` to conditionally apply error styling and display messages.

This setup gives you full control. You decide *when* validation runs (on submit, on blur, on change) and *how* errors are displayed. The core validation logic remains completely separate and reusable.

## The Trade-offs and When to Use Libraries

While this "vanilla" approach shines for many scenarios, it's not a silver bullet. Here's when you might still want a library like `react-hook-form` or `Formik`:

*   **Extremely Complex Forms:** If you have dozens of fields, intricate conditional logic (e.g., "if field A is X, then field B is required and must match pattern Y"), dynamic field arrays (add/remove items), or deeply nested object structures, managing all of that state and error propagation manually can become tedious. Libraries abstract away much of this boilerplate.
*   **Performance Optimization:** For very large forms with many inputs, `react-hook-form` is particularly good at minimizing re-renders by leveraging uncontrolled components and managing state at a lower level. My vanilla example uses controlled components, which re-render the component on every keystroke. For most forms, this is imperceptible, but it's a consideration.
*   **Schema-based Validation Integration:** If your backend already uses something like `Yup` or `Zod` for schema definition, and you want to reuse those exact schemas on the frontend with minimal effort, these libraries integrate seamlessly.
*   **Accessibility Features:** Some libraries offer built-in accessibility features (ARIA attributes, focus management) that you'd need to implement yourself with the vanilla approach.

My take is: start simple. If your form gets genuinely complex, *then* reach for the library. Don't pre-optimize for complexity you don't have. Many forms never reach that point, and you'll have saved yourself a dependency and learned more about the underlying mechanics.

## Advanced Pattern: Validation as a Hook

To make the integration even cleaner without losing the `domain-logic` separation, you can encapsulate the state and handlers into a custom hook. This is essentially what `react-hook-form` does internally, but you can build a lighter version for your specific use case.

```typescript react
import { useState, useCallback } from 'react';

// Assuming validateRegistration is imported from './domain/validation'
// import { validateRegistration } from './domain/validation';

type FieldErrors = { [key: string]: string[] };

// Dummy validateRegistration for standalone example
function validateRegistration(data: { email: string; password: string; confirmPassword: string }): FieldErrors | null {
    const errors: FieldErrors = {};
    if (!data.email) errors.email = ['Email is required.'];
    else if (!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(data.email)) errors.email = ['Email is not valid.'];

    if (!data.password) errors.password = ['Password is required.'];
    else if (data.password.length < 8) errors.password = ['Password must be at least 8 characters long.'];

    if (!data.confirmPassword) errors.confirmPassword = ['Confirm password is required.'];
    else if (data.password !== data.confirmPassword) errors.confirmPassword = ['Passwords do not match.'];

    return Object.keys(errors).length > 0 ? errors : null;
}

// A generic useForm hook that takes initial values and a validation function
function useForm<T extends Record<string, any>>(initialValues: T, validateFn: (data: T) => FieldErrors | null) {
  const [formData, setFormData] = useState<T>(initialValues);
  const [errors, setErrors] = useState<FieldErrors | null>(null);
  const [isSubmitted, setIsSubmitted] = useState(false);

  const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));

    if (errors && errors[name]) {
      const newErrors = { ...errors };
      delete newErrors[name];
      setErrors(Object.keys(newErrors).length > 0 ? newErrors : null);
    }
  }, [errors]);

  const handleBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
    if (isSubmitted || (errors && errors[e.target.name])) {
      const currentFieldData = { ...formData, [e.target.name]: e.target.value };
      const newErrors = validateFn(currentFieldData);
      setErrors(prevErrors => {
          const updatedErrors = { ...prevErrors, ...newErrors };
          if (!newErrors || !newErrors[e.target.name]) {
              delete updatedErrors[e.target.name];
          }
          return Object.keys(updatedErrors).length > 0 ? updatedErrors : null;
      });
    }
  }, [formData, isSubmitted, errors, validateFn]);

  const handleSubmit = useCallback((e: FormEvent) => {
    e.preventDefault();
    setIsSubmitted(true);
    const validationErrors = validateFn(formData);
    if (validationErrors) {
      setErrors(validationErrors);
      return false; // Indicate failure
    } else {
      setErrors(null);
      return true; // Indicate success
    }
  }, [formData, validateFn]);

  return {
    formData,
    errors,
    handleChange,
    handleBlur,
    handleSubmit,
    setFormData, // Expose for external updates if needed
    setErrors,
    setIsSubmitted
  };
}

// Now, our RegistrationForm becomes much cleaner
function RegistrationFormWithHook() {
  const { formData, errors, handleChange, handleBlur, handleSubmit } = useForm(
    { email: '', password: '', confirmPassword: '' },
    validateRegistration // Our pure validation function
  );

  const onSubmit = (e: FormEvent) => {
    if (handleSubmit(e)) {
      console.log('Form data is valid:', formData);
      alert('Registration successful!');
    } else {
      console.log('Form has errors.');
    }
  };

  return (
    <form onSubmit={onSubmit} className="p-4 max-w-md mx-auto bg-white shadow-md rounded-lg mt-8">
      <h2 className="text-xl font-bold mb-4">Register (with custom hook)</h2>
      <div className="mb-4">
        <label htmlFor="email2" className="block text-gray-700 text-sm font-bold mb-2">Email:</label>
        <input
          type="email"
          id="email2"
          name="email"
          value={formData.email}
          onChange={handleChange}
          onBlur={handleBlur}
          className={`shadow appearance-none border ${errors?.email ? 'border-red-500' : ''} rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline`}
        />
        {errors?.email && errors.email.map((msg, i) => (
          <p key={i} className="text-red-500 text-xs italic mt-1">{msg}</p>
        ))}
      </div>

      <div className="mb-4">
        <label htmlFor="password2" className="block text-gray-700 text-sm font-bold mb-2">Password:</label>
        <input
          type="password"
          id="password2"
          name="password"
          value={formData.password}
          onChange={handleChange}
          onBlur={handleBlur}
          className={`shadow appearance-none border ${errors?.password ? 'border-red-500' : ''} rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline`}
        />
        {errors?.password && errors.password.map((msg, i) => (
          <p key={i} className="text-red-500 text-xs italic mt-1">{msg}</p>
        ))}
      </div>

      <div className="mb-6">
        <label htmlFor="confirmPassword2" className="block text-gray-700 text-sm font-bold mb-2">Confirm Password:</label>
        <input
          type="password"
          id="confirmPassword2"
          name="confirmPassword"
          value={formData.confirmPassword}
          onChange={handleChange}
          onBlur={handleBlur}
          className={`shadow appearance-none border ${errors?.confirmPassword ? 'border-red-500' : ''} rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline`}
        />
        {errors?.confirmPassword && errors.confirmPassword.map((msg, i) => (
          <p key={i} className="text-red-500 text-xs italic mt-1">{msg}</p>
        ))}
      </div>

      <div className="flex items-center justify-between">
        <button
          type="submit"
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
        >
          Register
        </button>
      </div>
    </form>
  );
}

export default RegistrationFormWithHook;

The useForm hook centralizes all the UI-related form logic: state management, change handlers, and how errors are updated and cleared. The critical part is that it receives the validateFn as an argument. This keeps the core business validation logic fully decoupled, testable, and reusable, while providing a clean, ergonomic API for your React components. You can swap out validateRegistration for validateLogin or any other validation function without touching the useForm hook.

This pattern provides a sweet spot for many applications: the convenience of a hook for UI integration, without the heavy dependency or rigid structure of a full-blown form library. It's essentially building your own lightweight form library tailored to your domain.

Wrapping up

Before you reach for react-hook-form or Formik for every form, consider whether your validation logic truly needs that level of abstraction. For many common forms, treating your validation rules as pure, framework-agnostic domain logic and integrating them with simple React state management can lead to a simpler, more maintainable, and ultimately more robust solution. It reduces your bundle size, improves testability, and gives you explicit control over the user experience. Next time you're building a form, try writing your validation as a standalone pure function and integrating it directly into your component's useState or a simple custom hook. You might be surprised by how little you actually need.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕