Back to Blog
Form Validation: Treat Rules as Domain Logic, Not Framework Magic
8 min readJul 1, 20260 views

Form Validation: Treat Rules as Domain Logic, Not Framework Magic

We've all been there: another form, another npm install for a form library. But what if we're overcomplicating something that's fundamentally just business logic?

Software DesignFrontendWeb DevelopmentTypeScript
Share

by Sunil Band

The Form Validation Treadmill

Every project has forms. And every project, it seems, reinvents its approach to form validation. We reach for libraries like Formik or React Hook Form, which are powerful, no doubt. But I've found that sometimes, in our haste to manage state and rendering, we abstract away the core problem: validation rules are domain logic. They define what constitutes valid data in our application, independent of how that data is collected or displayed.

This isn't to say these libraries are bad. Far from it. They excel at managing form state, dirty states, submissions, and integrations with UI components. But when it comes to the actual validation rules – checking if an email is an email, if a password meets complexity requirements, or if a date is in the future – we often tie these rules too tightly to the form library's API. This makes them hard to reuse, hard to test in isolation, and ultimately, harder to reason about.

I believe we can have the best of both worlds: leverage framework conveniences for UI and state, while keeping our validation logic clean, portable, and fundamentally decoupled from the UI layer. It's about shifting our perspective to treat validation as a core part of our application's domain, not merely a UI concern.

Separating Concerns: Validation as Domain Rules

Think about it: a valid email address is valid whether it's typed into a React form, imported from a CSV, or received from an API. The rule for what constitutes a "valid email" shouldn't live inside a useForm hook or a Yup.object().shape() definition. It should be a standalone function or class that expresses that domain constraint.

By extracting validation rules into pure functions, we gain immense benefits:

  1. Reusability: The same isValidEmail function can be used for client-side forms, server-side API validation, or batch processing scripts.
  2. Testability: Pure functions are trivial to test. No need to mock React components or useForm contexts.
  3. Clarity: The rules become explicit. "This is how we validate an email," not "this is how we validate an email in this specific form."
  4. Decoupling: Your UI framework can change, your form library can change, but your core domain validation rules remain stable.

Let's look at a simple example. Instead of embedding a regex directly in a form library's schema, we can define a dedicated validation function.

typescript
// src/domain/validation/userValidation.ts

export type ValidationError = string;

export function validateEmail(email: string): ValidationError | null {
  if (!email) {
    return 'Email is required.';
  }
  // A simple regex for demonstration. In a real app, use a robust library or a more complex regex.
  const emailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
  if (!emailRegex.test(email)) {
    return 'Invalid email format.';
  }
  return null; // No error
}

export function validatePassword(password: string): ValidationError | null {
  if (!password) {
    return 'Password is required.';
  }
  if (password.length < 8) {
    return 'Password must be at least 8 characters long.';
  }
  if (!/[A-Z]/.test(password)) {
    return 'Password must contain at least one uppercase letter.';
  }
  if (!/[a-z]/.test(password)) {
    return 'Password must contain at least one lowercase letter.';
  }
  if (!/[0-9]/.test(password)) {
    return 'Password must contain at least one number.';
  }
  return null;
}

export function validateUsername(username: string): ValidationError | null {
  if (!username) {
    return 'Username is required.';
  }
  if (username.length < 3 || username.length > 20) {
    return 'Username must be between 3 and 20 characters.';
  }
  return null;
}

// You could even have an aggregate validator for a common domain object
export interface UserRegistrationData {
  username: string;
  email: string;
  password: string;
}

export type UserRegistrationErrors = {
  [K in keyof UserRegistrationData]?: ValidationError;
};

export function validateUserRegistration(data: UserRegistrationData): UserRegistrationErrors {
  const errors: UserRegistrationErrors = {};
  const emailError = validateEmail(data.email);
  if (emailError) errors.email = emailError;

  const passwordError = validatePassword(data.password);
  if (passwordError) errors.password = passwordError;

  const usernameError = validateUsername(data.username);
  if (usernameError) errors.username = usernameError;

  return errors;
}

This userValidation.ts file now holds our core business logic for user-related data integrity. It's TypeScript, so we get type safety, and it's framework-agnostic. You can import validateEmail anywhere, from your React frontend to a Node.js API endpoint.

Integrating with React Forms

Now, how do we bring these pure validation functions into a React component? We can integrate them quite easily, even with basic useState hooks, or by wrapping them with a lightweight form library if we need more complex state management.

Let's build a simple registration form using just useState to illustrate the point. We'll manage local component state for the form inputs and for the validation errors.

typescript
// src/components/RegistrationForm.tsx
import React, { useState, FormEvent } from 'react';
import {
  validateEmail,
  validatePassword,
  validateUsername,
  UserRegistrationData,
  UserRegistrationErrors,
} from '../domain/validation/userValidation'; // Import our domain validation functions

export function RegistrationForm() {
  const [formData, setFormData] = useState<UserRegistrationData>({
    username: '',
    email: '',
    password: '',
  });

  const [errors, setErrors] = useState<UserRegistrationErrors>({});
  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 field as user types, assuming immediate feedback
    if (errors[name as keyof UserRegistrationData]) {
      setErrors((prev) => {
        const newErrors = { ...prev };
        delete newErrors[name as keyof UserRegistrationData];
        return newErrors;
      });
    }
  };

  const validateForm = (): UserRegistrationErrors => {
    // Call our domain validation functions directly
    const newErrors: UserRegistrationErrors = {};
    const emailError = validateEmail(formData.email);
    if (emailError) newErrors.email = emailError;
    const passwordError = validatePassword(formData.password);
    if (passwordError) newErrors.password = passwordError;
    const usernameError = validateUsername(formData.username);
    if (usernameError) newErrors.username = usernameError;
    return newErrors;
  };

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    setIsSubmitted(true);

    const formErrors = validateForm();

    if (Object.keys(formErrors).length > 0) {
      setErrors(formErrors);
      console.log('Validation errors:', formErrors);
      return; // Stop submission if there are errors
    }

    // If no errors, proceed with form submission logic
    console.log('Form submitted successfully:', formData);
    alert('Registration successful!');
    // In a real app, you'd send this to an API, navigate, etc.
    setFormData({ username: '', email: '', password: '' }); // Clear form
    setErrors({});
    setIsSubmitted(false);
  };

  // Helper to show errors only after first submission attempt or interaction
  const getFieldError = (fieldName: keyof UserRegistrationData) => {
    return errors[fieldName];
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4 p-6 border rounded-lg shadow-md max-w-sm mx-auto bg-white">
      <div>
        <label htmlFor="username" className="block text-sm font-medium text-gray-700">Username</label>
        <input
          type="text"
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          className={`mt-1 block w-full border ${getFieldError('username') ? 'border-red-500' : 'border-gray-300'} rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500`}
        />
        {getFieldError('username') && (
          <p className="mt-1 text-sm text-red-600">{getFieldError('username')}</p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          className={`mt-1 block w-full border ${getFieldError('email') ? 'border-red-500' : 'border-gray-300'} rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500`}
        />
        {getFieldError('email') && (
          <p className="mt-1 text-sm text-red-600">{getFieldError('email')}</p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
        <input
          type="password"
          id="password"
          name="password"
          value={formData.password}
          onChange={handleChange}
          className={`mt-1 block w-full border ${getFieldError('password') ? 'border-red-500' : 'border-gray-300'} rounded-md shadow-sm p-2 focus:ring-blue-500 focus:border-blue-500`}
        />
        {getFieldError('password') && (
          <p className="mt-1 text-sm text-red-600">{getFieldError('password')}</p>
        )}
      </div>

      <button
        type="submit"
        className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
      >
        Register
      </button>
    </form>
  );
}

In this example, validateForm simply orchestrates calls to our imported domain validation functions. This component is now responsible for displaying the form, managing its local state, and reacting to user input, but not defining what makes an email valid. That logic lives in userValidation.ts.

If you later decide to use React Hook Form, you could easily integrate these same validateEmail, validatePassword, and validateUsername functions with its register method's validate option or with a custom resolver. No need to rewrite your core validation logic.

typescript
// Example using React Hook Form with our domain validation
import { useForm } from 'react-hook-form';
import { validateEmail } from '../domain/validation/userValidation';

function MyReactHookForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register("email", {
          validate: (value) => validateEmail(value) || true // `validate` expects true for valid, or string for error
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">Submit</button>
    </form>
  );
}

This pattern allows React Hook Form to handle the field registration, dirty state, and error display, while still relying on your pure, reusable domain validation functions for the actual logic. It's a powerful combination.

The Trade-offs

Nothing is a silver bullet, and this approach has its own set of trade-offs.

  1. More boilerplate for simple forms: For a dead-simple form with one or two fields and basic validation (like "required"), introducing a separate domain validation file might feel like overkill. You're adding a file and a layer of abstraction.
  2. No direct schema-level features: Libraries like Zod or Yup offer rich schema definitions that can infer types, handle transformations, and provide sophisticated conditional validation. If you're heavily reliant on these advanced schema features, wrapping them with pure functions might dilute some of their benefits. However, you can still use Zod within your domain validation functions to get those benefits, then export the pure function.
  3. Manual error aggregation: If you have a complex form with many fields, manually aggregating errors (as shown in validateUserRegistration) can become verbose. This is where a form library's resolver pattern shines, as it often handles this aggregation for you. However, you can still write a custom resolver that calls your domain validation functions.

Despite these, I find the benefits of maintainability, testability, and reusability far outweigh the costs for most real-world applications, especially as forms grow in complexity and validation logic becomes a critical part of the business domain.

Wrapping up

Stop treating form validation as an afterthought or a framework-specific implementation detail. Elevate it to a first-class citizen in your application's domain logic. By extracting your validation rules into pure, testable functions, you create a more robust, maintainable, and flexible codebase. You can then integrate these rules with any UI framework or form library, ensuring your business constraints are enforced consistently across your application.

Your next step: Look at a medium-complexity form in your current project. Identify one or two validation rules that are currently tied to a specific form library. Refactor them into standalone TypeScript functions that return a string | null error message. Then, re-integrate them into your form component, and see how much cleaner and more portable your validation code becomes. It's a small change with a big impact on long-term code health.

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. ☕