Form handling in React can quickly become verbose and repetitive. The Dynamic Property Name pattern (sometimes called the "single handler pattern") elegantly solves this problem by combining several modern JavaScript features. Let me walk you through several iterations of this powerful pattern, its benefits, and some enhancements.
- DRY Code: A single handler function handles all input changes, eliminating redundant code.
- Self-Documenting: The input's name attribute directly corresponds to the state property, making the relationship clear.
- Scalability: Adding new form fields requires minimal code changes - just add a new input with the appropriate name.
- Maintainability: Changes to validation or processing logic happen in one place.
- Immutability: Uses the spread operator to create a new state object rather than mutating the existing one.
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: '[email protected]'
});
function handleChange(e) {
const { name, value } = e.target;
setPerson({
...person,
[name]: value
});
}
return (
<form>
<input
name="firstName"
value={person.firstName}
onChange={handleChange}
/>
{/* More inputs following the same pattern */}
</form>
);
- Type Safety: TypeScript ensures your state and event handlers are properly typed.
- Input Validation: Integrated form validation that updates error state.
- Accessibility: Proper labels with htmlFor attributes and semantic HTML.
- Functional Updates: Using the callback form of setState setPerson(prev => ...) to avoid closure issues.
- Different Input Types: Handling checkbox, number, and text inputs appropriately.
- Visual Feedback: Error states reflected in the UI with appropriate styling.
- Performance: Clearing errors as soon as fields are corrected, giving immediate feedback.
import { useState, ChangeEvent } from 'react'
import { z } from 'zod'
// Define a schema for strong type validation
const PersonSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string(),
email: z.string().email('Invalid email address'),
age: z.number().positive('Age must be a positive number')
})
// Derive TypeScript type from the Zod schema
type Person = z.infer<typeof PersonSchema>
export default function PersonForm() {
// Initialize form state with default values
const [person, setPerson] = useState<Person>({
firstName: 'Barbara',
lastName: 'Hepworth',
email: '[email protected]',
age: 42
})
// Track validation errors for each field
const [errors, setErrors] = useState<Partial<Record<keyof Person, string>>>({})
// Track form submission status
const [isSubmitting, setIsSubmitting] = useState(false)
/**
* Universal change handler for all form inputs
* Uses computed property names to update the correct field
* Handles different input types appropriately (text, number, checkbox)
*/
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target
// Determine the new value based on input type
const newValue =
type === 'checkbox' ? checked : // For checkboxes, use the checked state
type === 'number' ? parseFloat(value) || 0 : // For numbers, parse to float (or 0 if invalid)
value // For text/email/etc, use the string value
// Update state using functional update to avoid closure issues
setPerson(prevPerson => ({
...prevPerson,
[name]: newValue
}))
// Clear error when field is modified to provide immediate feedback
if (errors[name as keyof Person]) {
setErrors(prevErrors => ({
...prevErrors,
[name]: undefined
}))
}
}
/**
* Validates the entire form using Zod schema
* Returns boolean indicating if form is valid
* Updates error state with any validation errors
*/
const validateForm = (): boolean => {
try {
// Attempt to parse/validate the current form data
PersonSchema.parse(person)
setErrors({}) // Clear all errors if valid
return true
} catch (error) {
// If validation fails, format and set error messages
if (error instanceof z.ZodError) {
const newErrors: Partial<Record<keyof Person, string>> = {}
error.errors.forEach(err => {
// Extract the field path and error message
const field = err.path[0] as keyof Person
newErrors[field] = err.message
})
setErrors(newErrors)
}
return false
}
}
/**
* Form submission handler
* Prevents default form behavior, validates, and processes data
*/
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
if (validateForm()) {
try {
// Simulate API call with delay
await new Promise(resolve => setTimeout(resolve, 500))
console.log('Form submitted successfully:', person)
// Here you would typically:
// - Send data to server
// - Display success message
// - Reset form or redirect
} catch (error) {
console.error('Submission error:', error)
// Handle submission error
}
}
setIsSubmitting(false)
}
/**
* Renders an input field with label and error message
* Reusable component to maintain consistency
*/
const renderField = (
id: keyof Person,
label: string,
type: string = 'text',
required: boolean = false
) => (
<div>
<label htmlFor={id}>
{label}{required && <span className='required'>*</span>}
</label>
<input
id={id}
name={id}
type={type}
value={person[id].toString()}
onChange={handleChange}
className={errors[id] ? 'error' : ''}
required={required}
/>
{errors[id] && <p className='error-message'>{errors[id]}</p>}
</div>
)
return (
<form onSubmit={handleSubmit}>
{/* Render form fields using our helper function */}
{renderField('firstName', 'First Name', 'text', true)}
{renderField('lastName', 'Last Name')}
{renderField('email', 'Email Address', 'email', true)}
{renderField('age', 'Age', 'number', true)}
<button
type='submit'
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
</form>
)
}
- Simplified State Management: React Hook Form handles form state internally, eliminating the need for multiple useState hooks.
- Automatic Form Validation: Form validation happens automatically on submit and can be configured for different events (blur, change, etc.).
- Performance Optimization: React Hook Form minimizes re-renders by isolating field updates.
- Declarative API: The
register
function elegantly connects form fields to validation and state. - Advanced Field Control: The
Controller
component provides more flexibility for complex inputs. - Built-in Submission State:
isSubmitting
is provided directly by the library. - Zod Integration: Using
@hookform/resolvers/zod
for seamless schema validation. - Type Safety: TypeScript types are derived from the Zod schema, ensuring consistency.
- Value Transformation: Proper handling of number inputs with
valueAsNumber
option. - Less Code, More Functionality: Overall reduction in code while maintaining or improving features.
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
// Define a schema for validation
const PersonSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string(),
email: z.string().email('Invalid email address'),
age: z.number().positive('Age must be a positive number').or(z.string().regex(/^\d+$/).transform(Number))
})
// Derive TypeScript type from the Zod schema
type Person = z.infer<typeof PersonSchema>
export default function PersonForm() {
/**
* Initialize React Hook Form with zod resolver
* defaultValues provides initial form data
*/
const {
control,
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<Person>({
resolver: zodResolver(PersonSchema),
defaultValues: {
firstName: 'Barbara',
lastName: 'Hepworth',
email: '[email protected]',
age: 42
}
})
/**
* Form submission handler
* Data is already validated by React Hook Form + Zod
*/
const onSubmit = async (data: Person) => {
try {
// Simulate API call with delay
await new Promise(resolve => setTimeout(resolve, 500))
console.log('Form submitted successfully:', data)
// Here you would typically:
// - Send data to server
// - Display success message
// - Reset form or redirect
} catch (error) {
console.error('Submission error:', error)
// Handle submission error
}
}
/**
* Renders an input field with label and error message
* Reusable component to maintain consistency
*/
const renderField = (
id: keyof Person,
label: string,
type: string = 'text',
required: boolean = false
) => (
<div>
<label htmlFor={id}>
{label}{required && <span className='required'>*</span>}
</label>
<input
id={id}
type={type}
{...register(id, { valueAsNumber: type === 'number' })}
className={errors[id] ? 'error' : ''}
/>
{errors[id] && <p className='error-message'>{errors[id]?.message?.toString()}</p>}
</div>
)
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Render basic text and email fields using our helper function */}
{renderField('firstName', 'First Name', 'text', true)}
{renderField('lastName', 'Last Name')}
{renderField('email', 'Email Address', 'email', true)}
{/* For the number field, we use Controller for more control */}
<div>
<label htmlFor='age'>
Age<span className='required'>*</span>
</label>
<Controller
name='age'
control={control}
render={({ field }) => (
<input
id='age'
type='number'
onChange={(e) => field.onChange(e.target.valueAsNumber || 0)}
value={field.value}
className={errors.age ? 'error' : ''}
/>
)}
/>
{errors.age && <p className='error-message'>{errors.age.message?.toString()}</p>}
</div>
<button
type='submit'
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
</form>
)
}