Generic Form Handlers: Reusable Type-Safe Components
Generic form handlers allow you to build a single form component that works with any form data shape, without repeating code for every form. Using TypeScript generics, you define a component that accepts a type parameter representing your form data structure. The component then enforces that all field names and types match that structure, preventing bugs when you add new forms. This approach scales from simple components with a few fields to complex multi-step forms.
How Do You Create a Generic Form Handler in TypeScript?
A generic form handler component accepts a type parameter T representing the form data structure. The handler function is typed so that the field name and update function only accept keys and values that exist in T. This ensures you can't accidentally set a field that doesn't exist or with the wrong type.
import { useState } from 'react';
// Generic form hook
interface FormState<T> {
data: T;
setField: <K extends keyof T>(field: K, value: T[K]) => void;
reset: () => void;
}
function useForm<T>(initialData: T): FormState<T> {
const [data, setData] = useState<T>(initialData);
const setField = <K extends keyof T>(field: K, value: T[K]) => {
setData(prevData => ({
...prevData,
[field]: value,
}));
};
const reset = () => setData(initialData);
return { data, setField, reset };
}
// Define two different form shapes
interface UserForm {
name: string;
email: string;
age: number;
}
interface ProductForm {
title: string;
price: number;
inStock: boolean;
}
// Use the generic hook with UserForm
export const UserFormComponent = () => {
const form = useForm<UserForm>({
name: '',
email: '',
age: 0,
});
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// TypeScript knows 'name' is a valid field and the value is a string
form.setField('name', e.currentTarget.value);
};
const handleAgeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// TypeScript knows 'age' is a valid field and the value must be a number
form.setField('age', parseInt(e.currentTarget.value, 10));
};
return (
<form>
<input
value={form.data.name}
onChange={handleNameChange}
placeholder="Name"
/>
<input
type="email"
value={form.data.email}
onChange={(e) => form.setField('email', e.currentTarget.value)}
placeholder="Email"
/>
<input
type="number"
value={form.data.age}
onChange={handleAgeChange}
placeholder="Age"
/>
<button type="button" onClick={form.reset}>Reset</button>
</form>
);
};
The <K extends keyof T> constraint ensures that the field name is one of the keys in T. If you try to call form.setField('nonexistent', 'value'), TypeScript errors immediately.
Creating a Generic Form Input Component
Instead of repeating the same input markup, create a generic component that handles the wiring automatically:
import { React } from 'react';
interface GenericInputProps<T, K extends keyof T> {
label: string;
field: K;
value: T[K];
onChange: <F extends K>(field: F, value: T[F]) => void;
type?: string;
placeholder?: string;
}
function GenericInput<T, K extends keyof T>(
props: GenericInputProps<T, K>
): React.ReactElement {
const { label, field, value, onChange, type = 'text', placeholder } = props;
// Ensure value is a string before passing to input
const stringValue = typeof value === 'string' ? value : String(value);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Pass the field and value back up
onChange(field, e.currentTarget.value as T[K]);
};
return (
<div>
<label>{label}</label>
<input
type={type}
value={stringValue}
onChange={handleChange}
placeholder={placeholder}
/>
</div>
);
}
// Use the generic input with UserForm
export const UserFormWithGenericInput = () => {
const form = useForm<UserForm>({
name: '',
email: '',
age: 0,
});
return (
<form>
<GenericInput
label="Name"
field="name"
value={form.data.name}
onChange={form.setField}
placeholder="Your name"
/>
<GenericInput
label="Email"
field="email"
value={form.data.email}
onChange={form.setField}
type="email"
placeholder="Your email"
/>
<GenericInput
label="Age"
field="age"
value={form.data.age}
onChange={form.setField}
type="number"
placeholder="Your age"
/>
<button type="button" onClick={form.reset}>Reset</button>
</form>
);
};
This generic input component works with any form type. When you pass field="name", TypeScript knows the value is a string. When you pass field="age", it knows the value is a number. The component enforces these constraints at compile time.
Validating Generic Form Data Before Submission
After gathering form data, you often need to validate it. A generic validate function works with any form shape:
// Generic validation function
type ValidationError<T> = {
[K in keyof T]?: string; // Each field can have an error message
};
function validateUserForm(data: UserForm): ValidationError<UserForm> {
const errors: ValidationError<UserForm> = {};
if (!data.name) errors.name = 'Name is required';
if (!data.email) errors.email = 'Email is required';
if (data.age < 18) errors.age = 'Must be at least 18';
return errors;
}
// Use in the form component
export const ValidatedUserForm = () => {
const form = useForm<UserForm>({
name: '',
email: '',
age: 0,
});
const [errors, setErrors] = useState<ValidationError<UserForm>>({});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const newErrors = validateUserForm(form.data);
setErrors(newErrors);
if (Object.keys(newErrors).length === 0) {
console.log('Form submitted:', form.data);
}
};
return (
<form onSubmit={handleSubmit}>
<GenericInput
label="Name"
field="name"
value={form.data.name}
onChange={form.setField}
/>
{errors.name && <p style={{ color: 'red' }}>{errors.name}</p>}
{/* Other fields... */}
<button type="submit">Submit</button>
</form>
);
};
The ValidationError<T> type creates an object with the same keys as T, where each value is an optional error string. This ensures you can't add error keys that don't exist in your form.
Key Takeaways
- Generic form hooks use
<T>to represent any form data shape, allowing reuse across multiple forms. - The
<K extends keyof T>constraint ensures field names are valid keys in the form type. - Generic input components automatically enforce type safety by knowing the type of each field.
- Validation functions can be typed generically to check form data of any shape.
- This pattern prevents bugs when you have many forms because TypeScript verifies every interaction.
Frequently Asked Questions
Why use generics instead of any for the form type?
Using any disables type checking. If you use any, TypeScript can't warn you when you reference a field that doesn't exist or assign the wrong type. Generics preserve type safety across different form shapes.
Can I use a generic form handler with async validation?
Yes, create an async validation function that returns a Promise<ValidationError<T>>. Call it in an async handleSubmit, and update the error state when the validation completes.
How do I handle nested form data with generics?
If your form has nested objects (like a user with an address), extend the type parameter constraint: function useForm<T extends Record<string, any>>(). This allows nested objects while maintaining type safety.
What if a form field can be multiple types (like string or number)?
Use a union type in the form interface: count: string | number. Then, in the handler, narrow the type before using it.
Can I generate forms from TypeScript interfaces automatically?
Yes, libraries like react-hook-form and zod can introspect type definitions and generate form fields. These are especially useful for large, complex forms.