
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?
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:
- Reusability: The same
isValidEmailfunction can be used for client-side forms, server-side API validation, or batch processing scripts. - Testability: Pure functions are trivial to test. No need to mock React components or
useFormcontexts. - Clarity: The rules become explicit. "This is how we validate an email," not "this is how we validate an email in this specific form."
- 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.
// 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.
// 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.
// 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.
- 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.
- 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.
- 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.

React Data Grids: Beyond the Marketing Demo, Benchmarking Real-World Performance
When your client's dataset grows from hundreds to tens of thousands of rows, that slick data grid component you picked suddenly turns into a janky, unresponsive nightmare. Most marketing demos for React data grids show off beautiful UIs with trivial amounts of data. But what happens when you throw 5

Raw WebSockets: Ditching Socket.IO for Deeper Control
Socket.IO is often the go-to for real-time applications, and for good reason: it handles reconnects, fallbacks, and rooms with ease. But sometimes, that abstraction comes at a cost, making debugging harder and obscuring the underlying protocol. I found myself needing more direct control, and that me

Escaping SaaS Lock-in: Automating Workflows with self-hosted `automatisch`
I've used my fair share of SaaS workflow automation tools, and they're great until the costs skyrocket or a niche integration is missing. automatish offers a refreshing, self-hosted alternative.


















