Building Type-Safe React Forms: Beyond useState
Building forms in React can quickly become complex, especially when dealing with validation, error handling, and type safety. In this article, we'll explore how to create a robust, type-safe form system that goes beyond simple useState
implementations.
The Problem with Traditional Form Approaches
Let's start with a common way of handling forms in React:
function UserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validation logic here
// API calls here
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
{errors.name && <span>{errors.name}</span>}
{/* Repeat for email and age */}
</form>
);
}
This approach has several problems:
- No type safety for form values
- Repetitive state declarations
- Validation logic mixed with UI
- No centralized form state
- Hard to reuse validation logic
Building a Type-Safe Form Hook
Let's create a custom hook that addresses these issues:
interface FormField<T> {
value: T;
error?: string;
touched: boolean;
validators?: ((value: T) => string | undefined)[];
}
type FormState<T> = {
[K in keyof T]: FormField<T[K]>;
};
function useForm<T extends Record<string, any>>(
initialValues: T,
onSubmit: (values: T) => void | Promise<void>
) {
const [formState, setFormState] = useState<FormState<T>>(() => {
const initial: Partial<FormState<T>> = {};
for (const [key, value] of Object.entries(initialValues)) {
initial[key as keyof T] = {
value,
touched: false,
validators: [],
};
}
return initial as FormState<T>;
});
const setFieldValue = useCallback(<K extends keyof T>(
field: K,
value: T[K]
) => {
setFormState((prev) => ({
...prev,
[field]: {
...prev[field],
value,
touched: true,
},
}));
}, []);
const registerField = useCallback(<K extends keyof T>(
field: K,
validators: ((value: T[K]) => string | undefined)[] = []
) => {
setFormState((prev) => ({
...prev,
[field]: {
...prev[field],
validators,
},
}));
}, []);
const validateField = useCallback(<K extends keyof T>(
field: K
): boolean => {
const fieldState = formState[field];
const validators = fieldState.validators || [];
for (const validator of validators) {
const error = validator(fieldState.value);
if (error) {
setFormState((prev) => ({
...prev,
[field]: { ...prev[field], error },
}));
return false;
}
}
setFormState((prev) => ({
...prev,
[field]: { ...prev[field], error: undefined },
}));
return true;
}, [formState]);
const validateForm = useCallback((): boolean => {
let isValid = true;
for (const field of Object.keys(formState)) {
if (!validateField(field as keyof T)) {
isValid = false;
}
}
return isValid;
}, [formState, validateField]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
const values = Object.entries(formState).reduce(
(acc, [key, field]) => ({
...acc,
[key]: field.value,
}),
{} as T
);
onSubmit(values);
}
},
[formState, validateForm, onSubmit]
);
return {
formState,
setFieldValue,
registerField,
validateField,
validateForm,
handleSubmit,
};
}
Creating Reusable Validators
Let's create some type-safe validators:
const createValidator = <T>(
predicate: (value: T) => boolean,
errorMessage: string
) => {
return (value: T): string | undefined =>
predicate(value) ? undefined : errorMessage;
};
export const validators = {
required: createValidator(
(value: any) => value !== undefined && value !== '',
'This field is required'
),
email: createValidator(
(value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
'Invalid email address'
),
minLength: (min: number) =>
createValidator(
(value: string) => value.length >= min,
`Must be at least ${min} characters`
),
maxLength: (max: number) =>
createValidator(
(value: string) => value.length <= max,
`Must be no more than ${max} characters`
),
number: createValidator(
(value: string) => !isNaN(Number(value)),
'Must be a number'
),
};
Using the Form Hook
Here's how to use our type-safe form system:
interface UserFormData {
name: string;
email: string;
age: string;
}
function UserForm() {
const {
formState,
setFieldValue,
registerField,
handleSubmit,
} = useForm<UserFormData>(
{
name: '',
email: '',
age: '',
},
async (values) => {
console.log('Form submitted:', values);
// API call here
}
);
useEffect(() => {
registerField('name', [validators.required, validators.minLength(2)]);
registerField('email', [validators.required, validators.email]);
registerField('age', [validators.required, validators.number]);
}, [registerField]);
return (
<form onSubmit={handleSubmit}>
<div>
<input
value={formState.name.value}
onChange={(e) => setFieldValue('name', e.target.value)}
/>
{formState.name.touched && formState.name.error && (
<span>{formState.name.error}</span>
)}
</div>
<div>
<input
value={formState.email.value}
onChange={(e) => setFieldValue('email', e.target.value)}
/>
{formState.email.touched && formState.email.error && (
<span>{formState.email.error}</span>
)}
</div>
<div>
<input
value={formState.age.value}
onChange={(e) => setFieldValue('age', e.target.value)}
/>
{formState.age.touched && formState.age.error && (
<span>{formState.age.error}</span>
)}
</div>
<button type="submit">Submit</button>
</form>
);
}
Creating Reusable Form Fields
To reduce boilerplate even further, let's create a reusable form field component:
interface FormFieldProps<T, K extends keyof T> {
name: K;
label: string;
formState: FormState<T>;
setFieldValue: (field: K, value: T[K]) => void;
}
function FormField<T, K extends keyof T>({
name,
label,
formState,
setFieldValue,
}: FormFieldProps<T, K>) {
const field = formState[name];
return (
<div className="form-field">
<label>{label}</label>
<input
value={field.value as string}
onChange={(e) => setFieldValue(name, e.target.value as T[K])}
className={field.error ? 'error' : ''}
/>
{field.touched && field.error && (
<span className="error-message">{field.error}</span>
)}
</div>
);
}
Now our form becomes much cleaner:
function UserForm() {
const form = useForm<UserFormData>(
{
name: '',
email: '',
age: '',
},
async (values) => {
console.log('Form submitted:', values);
}
);
useEffect(() => {
form.registerField('name', [validators.required, validators.minLength(2)]);
form.registerField('email', [validators.required, validators.email]);
form.registerField('age', [validators.required, validators.number]);
}, [form.registerField]);
return (
<form onSubmit={form.handleSubmit}>
<FormField
name="name"
label="Name"
formState={form.formState}
setFieldValue={form.setFieldValue}
/>
<FormField
name="email"
label="Email"
formState={form.formState}
setFieldValue={form.setFieldValue}
/>
<FormField
name="age"
label="Age"
formState={form.formState}
setFieldValue={form.setFieldValue}
/>
<button type="submit">Submit</button>
</form>
);
}
Advanced Features
Here are some additional features we could add to our form system:
1. Form-Level Validation
interface FormValidation<T> {
(values: T): Partial<Record<keyof T, string>> | undefined;
}
// Add to useForm parameters
formValidation?: FormValidation<T>
2. Async Validation
const asyncValidator = <T>(
validate: (value: T) => Promise<string | undefined>
) => {
return async (value: T): Promise<string | undefined> => {
return await validate(value);
};
};
// Example usage
const uniqueEmailValidator = asyncValidator(async (email: string) => {
const response = await checkEmailExists(email);
return response.exists ? 'Email already exists' : undefined;
});
3. Field Dependencies
interface FieldDependency<T, K extends keyof T> {
fields: K[];
validator: (values: Pick<T, K>) => string | undefined;
}
Performance Considerations
- Memoization: Our hook uses
useCallback
for functions to prevent unnecessary rerenders. - Selective Updates: The form state updates only the changed fields, not the entire form state.
- Validation Optimization: Validation runs only on touched fields or during form submission.
Testing Your Forms
Here's how to test your type-safe forms:
import { renderHook, act } from '@testing-library/react-hooks';
describe('useForm', () => {
it('should initialize with correct values', () => {
const { result } = renderHook(() =>
useForm(
{ name: '', email: '' },
() => {}
)
);
expect(result.current.formState).toEqual({
name: { value: '', touched: false, validators: [] },
email: { value: '', touched: false, validators: [] },
});
});
it('should update field value', () => {
const { result } = renderHook(() =>
useForm(
{ name: '' },
() => {}
)
);
act(() => {
result.current.setFieldValue('name', 'John');
});
expect(result.current.formState.name.value).toBe('John');
expect(result.current.formState.name.touched).toBe(true);
});
});
Conclusion
Building type-safe forms in React requires careful consideration of:
- State management
- Validation
- Type safety
- Reusability
- Performance
Our solution provides:
- Full type safety with TypeScript
- Reusable validation logic
- Centralized form state
- Performance optimizations
- Easy testing capabilities
Remember that while this implementation is quite robust, you might want to consider existing form libraries like Formik or React Hook Form for production applications. However, understanding these patterns helps you make better decisions about when to use existing solutions versus building custom implementations.