Form Handling: react-hook-form vs Controlled Components
Effective form handling is a cornerstone of robust web applications. React offers several paradigms for managing form state and submission, with two…
Effective form handling is a cornerstone of robust web applications. React offers several paradigms for managing form state and submission, with two prominent approaches being controlled components and libraries like react-hook-form. Choosing between them involves evaluating factors like form complexity, performance requirements, and validation needs.
Controlled Components: The React Way
In React, controlled components are elements where form input values are controlled by React state. Every state change (e.g., a keystroke) triggers an onChange event handler, which updates the component's state, and re-renders the component to reflect the new value. This provides a single source of truth for form data within the React component lifecycle.
Implementation Details
For each input field, you bind its value prop to a piece of React state and its onChange prop to a function that updates that state. Validation typically occurs synchronously within the onChange handler or upon a blur event, leading to immediate feedback.
import React, { useState } from 'react';
function ControlledContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prevData => ({ ...prevData, [name]: value }));
// Basic inline validation (synchronous)
let newErrors = { ...errors };
if (name === 'email' && !value.includes('@')) {
newErrors.email = 'Invalid email address';
} else {
delete newErrors.email;
}
setErrors(newErrors);
};
const handleSubmit = (e) => {
e.preventDefault();
// Perform final validation before submission
const finalErrors = {};
if (!formData.name.trim()) finalErrors.name = 'Name is required';
if (!formData.email.includes('@')) finalErrors.email = 'Valid email is required';
if (Object.keys(finalErrors).length > 0) {
setErrors(finalErrors);
console.log('Validation errors:', finalErrors);
return;
}
console.log('Form submitted:', formData);
// API call or further processing
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
{errors.name && <p style={{ color: 'red' }}>{errors.name}</p>}
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
{errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}
</div>
<div>
<label htmlFor="message">Message:</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
/>
</div>
<button type="submit">Submit</button>
</form>
);
}
Advantages and Disadvantages
- Pros:
- Direct and explicit control over form state.
- Immediate feedback for validation.
- Simpler for small, straightforward forms.
- Cons:
- Performance Overhead: Every keystroke triggers a state update and a component re-render. For forms with many input fields (e.g., 20+ fields) or complex validation logic, this can lead to noticeable performance degradation due to excessive re-renders, especially on lower-end devices.
- Boilerplate: Requires significant boilerplate code for each input (
value,onChange, state management, validation logic). - Synchronization Issues: Managing validation state alongside input state can become complex.
Uncontrolled Components and react-hook-form
react-hook-form (RHF) takes a different approach, leveraging uncontrolled components and React refs. Instead of re-rendering the component on every input change, RHF registers input fields and reads their values directly from the DOM when needed, typically on form submission or explicit validation triggers. This minimizes re-renders, making it highly performant for large forms.
Key Concepts
- Uncontrolled Inputs: Input values are managed by the DOM itself, not by React state. RHF accesses these values using refs.
- Registration: You "register" your input fields with RHF using the
registerfunction provided byuseForm. This attaches the necessary ref and event handlers. - Validation Strategy: RHF defaults to validating on
onSubmit, meaning validation errors are shown after the user attempts to submit the form. This can be configured to validate ononChange,onBlur, or a combination using themodeoption (e.g.,useForm({ mode: 'onBlur' })). - Schema Validation: RHF integrates seamlessly with schema validation libraries like Zod, Yup, Joi, or Superstruct, allowing for powerful, declarative validation rules.
Implementation with Zod
Integrating RHF with a schema validation library like Zod provides a robust and centralized way to define form shape and validation rules. Zod offers a concise API for defining schemas and can infer TypeScript types directly from them, improving type safety.
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
// Define your Zod schema
const contactFormSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }).max(100, { message: 'Name too long' }),
email: z.string().email({ message: 'Invalid email address' }),
subject: z.string().optional(),
message: z.string().min(10, { message: 'Message must be at least 10 characters' }),
age: z.number().int().min(18, { message: 'Must be at least 18' }).optional()
}).refine(data => data.email.endsWith('@example.com') || data.email.endsWith('@stackvora.tech'), {
message: 'Email must be from example.com or stackvora.tech domain',
path: ['email'], // Path to the field that caused the error
});
// Infer TypeScript type from Zod schema
type ContactFormInputs = z.infer;
function RHFContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm({
resolver: zodResolver(contactFormSchema),
mode: 'onBlur', // Validate on blur, not every keystroke
defaultValues: { // Optional: Pre-fill form fields
name: '',
email: '',
subject: '',
message: '',
age: undefined
}
});
const onSubmit = async (data: ContactFormInputs) => {
console.log('Form data:', data);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
alert('Form submitted successfully!');
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name:</label>
<input
type="text"
id="name"
{...register('name')}
/>
{errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
{...register('email')}
/>
{errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
</div>
<div>
<label htmlFor="subject">Subject:</label>
<input
type="text"
id="subject"
{...register('subject')}
/>
{errors.subject && <p style={{ color: 'red' }}>{errors.subject.message}</p>}
</div>
<div>
<label htmlFor="message">Message:</label>
<textarea
id="message"
{...register('message')}
/>
{errors.message && <p style={{ color: 'red' }}>{errors.message.message}</p>}
</div>
<div>
<label htmlFor="age">Age:</label>
<input
type="number"
id="age"
{...register('age', { valueAsNumber: true })} // Cast to number
/>
{errors.age && <p style={{ color: 'red' }}>{errors.age.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Advantages and Disadvantages
- Pros:
- Performance: Minimal re-renders (only on error/state updates, not every keystroke). This is the primary driver for its adoption in large, complex forms.
- Less Boilerplate: The
{...register('fieldName')}syntax is concise. - Strong Typing: Excellent TypeScript support with Zod or similar schema validators.
- Flexible Validation: Supports various validation modes (on submit, on blur, on change) and integrates easily with external schema libraries.
- Built-in features: Reset, set value, watch fields, focus management, array fields (
useFieldArray).
- Cons:
- Learning Curve: A new API to learn compared to native controlled components.
- Less "React-y" for simple forms: For a form with 1-2 fields, controlled components might feel more straightforward.
- Debuggability: Directly interacting with the DOM via refs can sometimes be slightly harder to debug than pure React state.
Comparative Analysis
Here's a breakdown of key aspects:
| Feature | Controlled Components | react-hook-form |
|---|---|---|
| State Management | React state (useState) per input or form-wide object. |
Internal store, values managed by DOM refs. |
| Re-renders on Keystroke | Yes, for the component containing the form. | No, only on state changes relevant to RHF (e.g., error updates). |
| Validation Logic | Manual, often within onChange or onBlur handlers. |
Declarative via schema resolvers (Zod, Yup) or built-in rules. |
| Boilerplate | High, especially for complex forms. | Low, {...register()} for most inputs. |
| Performance (Large Forms) | Can degrade due to excessive re-renders. | Excellent, due to minimal re-renders. |
| Integration with TS | Manual type definitions for form data. | Strong, schema inference works seamlessly. |
| Use Case | Simple forms (1-5 inputs), immediate synchronous feedback is crucial. | Complex forms (5+ inputs, nested fields, arrays), performance-critical. |
Inline Error Display Best Practices
Regardless of the form handling method, always display validation errors directly below the affected input field. This is critical for:
- Accessibility (WCAG 2.1 Success Criterion 3.3.1): Users with visual impairments or cognitive disabilities rely on clear associations between inputs and their errors. A screen reader should announce the error when focus moves to or near the invalid field.
- User Experience: Users can quickly identify and correct mistakes without searching for a generic error message at the top or bottom of the form.
- Conversion Rates: Frustrated users are more likely to abandon a form. Clear, immediate, and contextual feedback improves completion rates.
Avoid displaying a single banner at the top of the form for all errors, as it requires the user to mentally map errors to fields. Each field should have its own associated error message, as demonstrated in the RHF example with {errors.fieldName && <p>{errors.fieldName.message}</p>}.
Common Pitfalls
- Over-optimizing simple forms: For a form with two text inputs, the overhead of
react-hook-formmight not be justified; controlled components are simpler. - Incorrect
modeselection with RHF: Usingmode: 'onChange'withreact-hook-formwill lead to more re-renders and potentially negate some of its performance benefits, though still often less than fully controlled components. For best performance, useonBluroronSubmit. - Not clearing errors: Ensure validation errors clear as the user corrects the input. RHF handles this automatically based on its validation mode. With controlled components, this needs to be explicitly managed in the
onChangehandler. - Improper type handling for number inputs with RHF: When using
type="number", the value from the DOM is always a string. Use{...register('fieldName', { valueAsNumber: true })}to automatically cast it to a number, preventing type mismatches with your Zod schema. - Accessibility of error messages: Ensure error messages are associated with their inputs using
aria-describedbyfor screen readers, especially if styling removes the visual proximity (though generally, direct visual placement is preferred). RHF typically handles some of this automatically with itsrefsystem.