Formik vs. React Hook Form: A Detailed Comparison
When building forms in React, managing state, validation, and submissions can quickly become complex. Libraries like Formik and React Hook Form (RHF) aim to simplify this.
1. Formik
Formik is a popular library for building forms in React. It provides helper methods and components to handle form state, validation, and submission, abstracting away much of the boilerplate.
Core Philosophy
Formik’s core philosophy is to manage the entire form lifecycle within its <Formik> component or useFormik hook. It handles value changes, blur events, validation triggering, and submission, providing you with props and state to render your form.
How it Works
Formik exposes its state and methods (like handleChange, handleBlur, handleSubmit, values, errors, touched) via a render prop (for <Formik> component) or as return values from the useFormik hook.
Simple Formik Example: Basic Login Form
Explanation:
- We use
useFormikto initialize our form. initialValuessets the starting state for our form fields.validationSchema(orvalidatefunction) defines our validation rules. Here we use Yup.onSubmitis called when the form is submitted and passes the form values.formik.values,formik.errors,formik.touched,formik.handleChange,formik.handleBlur,formik.handleSubmitare all provided by theuseFormikhook.
// src/components/SimpleFormikForm.jsx
import React from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup'; // For validation
const SimpleFormikForm = () => {
const formik = useFormik({
initialValues: {
email: '',
password: '',
},
validationSchema: Yup.object({
email: Yup.string().email('Invalid email address').required('Required'),
password: Yup.string().min(6, 'Must be 6 characters or more').required('Required'),
}),
onSubmit: values => {
alert(JSON.stringify(values, null, 2));
console.log('Form submitted:', values);
},
});
return (
<div style={{ border: '1px solid #ccc', padding: '20px', margin: '20px' }}>
<h3>Simple Formik Form</h3>
<form onSubmit={formik.handleSubmit}>
<div>
<label htmlFor="email">Email Address</label>
<input
id="email"
name="email"
type="email"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.email}
/>
{formik.touched.email && formik.errors.email ? (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.email}</div>
) : null}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.password}
/>
{formik.touched.password && formik.errors.password ? (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.password}</div>
) : null}
</div>
<button type="submit" style={{ marginTop: '20px', padding: '10px 20px' }}>Submit</button>
</form>
</div>
);
};
export default SimpleFormikForm;
// In your App.js:
// import SimpleFormikForm from './components/SimpleFormikForm';
// function App() { return <SimpleFormikForm />; }
Complex Formik Example: User Profile with Nested Address
Explanation:
- We use a more complex Yup schema with nested objects (
address.street,address.zipCode). - Notice how
nameattributes in inputs directly map to the nested path (e.g.,address.street). - Formik handles the nested updates automatically.
// src/components/ComplexFormikForm.jsx
import React from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
const ComplexFormikForm = () => {
const formik = useFormik({
initialValues: {
fullName: '',
email: '',
age: '',
address: {
street: '',
city: '',
zipCode: '',
},
hobbies: [], // Array of strings
},
validationSchema: Yup.object({
fullName: Yup.string().max(50, 'Must be 50 characters or less').required('Full Name is required'),
email: Yup.string().email('Invalid email address').required('Email is required'),
age: Yup.number().min(18, 'Must be at least 18').required('Age is required').typeError('Age must be a number'),
address: Yup.object({
street: Yup.string().required('Street is required'),
city: Yup.string().required('City is required'),
zipCode: Yup.string().matches(/^\d{5}(-\d{4})?$/, 'Invalid Zip Code').required('Zip Code is required'),
}),
hobbies: Yup.array().of(Yup.string()).min(1, 'Please select at least one hobby').required('Hobbies are required'),
}),
onSubmit: values => {
alert(JSON.stringify(values, null, 2));
console.log('Form submitted:', values);
},
});
const hobbiesOptions = ['Reading', 'Gaming', 'Hiking', 'Cooking', 'Sports'];
return (
<div style={{ border: '1px solid #ccc', padding: '20px', margin: '20px' }}>
<h3>Complex Formik Form</h3>
<form onSubmit={formik.handleSubmit}>
{/* Basic Fields */}
<div>
<label htmlFor="fullName">Full Name</label>
<input
id="fullName"
name="fullName"
type="text"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.fullName}
/>
{formik.touched.fullName && formik.errors.fullName && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.fullName}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="email">Email Address</label>
<input
id="email"
name="email"
type="email"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.email}
/>
{formik.touched.email && formik.errors.email && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.email}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="age">Age</label>
<input
id="age"
name="age"
type="number"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.age}
/>
{formik.touched.age && formik.errors.age && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.age}</div>
)}
</div>
{/* Nested Address Fields */}
<fieldset style={{ border: '1px solid #eee', padding: '15px', marginTop: '20px' }}>
<legend>Address</legend>
<div>
<label htmlFor="address.street">Street</label>
<input
id="address.street"
name="address.street"
type="text"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.address.street}
/>
{formik.touched.address?.street && formik.errors.address?.street && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.address.street}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="address.city">City</label>
<input
id="address.city"
name="address.city"
type="text"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.address.city}
/>
{formik.touched.address?.city && formik.errors.address?.city && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.address.city}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="address.zipCode">Zip Code</label>
<input
id="address.zipCode"
name="address.zipCode"
type="text"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.address.zipCode}
/>
{formik.touched.address?.zipCode && formik.errors.address?.zipCode && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.address.zipCode}</div>
)}
</div>
</fieldset>
{/* Array of Hobbies */}
<fieldset style={{ border: '1px solid #eee', padding: '15px', marginTop: '20px' }}>
<legend>Hobbies</legend>
{hobbiesOptions.map(hobby => (
<div key={hobby} style={{ marginBottom: '5px' }}>
<input
type="checkbox"
id={`hobby-${hobby}`}
name="hobbies"
value={hobby}
checked={formik.values.hobbies.includes(hobby)}
onChange={(e) => {
const selectedHobbies = e.target.checked
? [...formik.values.hobbies, e.target.value]
: formik.values.hobbies.filter((h) => h !== e.target.value);
formik.setFieldValue('hobbies', selectedHobbies);
formik.setFieldTouched('hobbies', true, false); // Manually set touched
}}
onBlur={formik.handleBlur} // OnBlur helps set touched for validation
/>
<label htmlFor={`hobby-${hobby}`} style={{ marginLeft: '5px' }}>{hobby}</label>
</div>
))}
{formik.touched.hobbies && formik.errors.hobbies && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{formik.errors.hobbies}</div>
)}
</fieldset>
<button type="submit" style={{ marginTop: '20px', padding: '10px 20px' }}>Submit</button>
</form>
</div>
);
};
export default ComplexFormikForm;
// In your App.js:
// import ComplexFormikForm from './components/ComplexFormikForm';
// function App() { return <ComplexFormikForm />; }
Formik Error Cases and Solutions
Error Case 1: Validation doesn’t show immediately on blur.
- Problem: You type, then click away, and the error doesn’t appear until you submit or type again.
- Solution: Ensure you are using
formik.handleBluron your input fields. Formik uses thetouchedobject to determine when to show errors. An error for a field (formik.errors.fieldName) is only shown if the field has also beentouched(formik.touched.fieldName).
Error Case 2: Deeply nested object updates cause issues (less common with Formik).
- Problem: You’re trying to update a deeply nested property (e.g.,
user.address.location.lat), and it’s not updating correctly or throwing errors. - Solution: For standard inputs, Formik’s
name="address.location.lat"automatically handles this. For custom components or when you need more control, useformik.setFieldValue('address.location.lat', newValue)for specific updates. This is how the hobbies checkbox example handles it.
Error Case 3: Performance issues with large forms.
- Problem: When you type in one field, the entire form (or a large portion of it) re-renders, causing lag. This is because Formik’s state (
values,errors,touched) is a single object. - Solution:
FastFieldComponent: For inputs that don’t need to re-render when other unrelated fields change, wrap them in<FastField>fromformik. This component implementsshouldComponentUpdateto prevent unnecessary re-renders.- Optimize Custom Inputs: If you use custom components, ensure they are memoized with
React.memoand only re-render when their relevant props change.
Formik Pros & Cons
| Feature | Pros | Cons |
|---|---|---|
| Ease of Use | - All-in-one solution for forms. | - Can be a bit verbose (more props to pass around). |
- Manages values, errors, touched out of the box. | - Initial learning curve for useFormik or render props. | |
| Validation | - Excellent integration with Yup. | - Can trigger more re-renders than necessary due to its single state object. |
- Flexible validate function for custom logic. | ||
| Performance | - FastField helps optimize large forms. | - Can have re-rendering issues on large/complex forms if not optimized (e.g., without FastField). |
| - Relies on full re-renders for value changes, which can be less efficient than uncontrolled inputs. | ||
| Boilerplate | - Reduces boilerplate compared to building everything with useState. | - Still involves some boilerplate (e.g., manually linking onChange, onBlur, value, and error messages for each input). |
| Community | - Large, mature community and extensive documentation. |
2. React Hook Form (RHF)
React Hook Form is another popular library, but it takes a different approach to form management, focusing on performance and simplicity by leveraging uncontrolled components and native HTML form validation.
Core Philosophy
RHF emphasizes performance by minimizing re-renders. It achieves this by working largely with uncontrolled components, where form data is read directly from the DOM rather than being constantly managed in React state. It also tries to align with native HTML form validation where possible.
How it Works
RHF uses refs to register inputs with the form. When you submit the form, RHF reads the values from the registered inputs directly. Validation is triggered efficiently, often on blur or submit.
Simple React Hook Form Example: Basic Login Form
Explanation:
- We use
useFormto get methods likeregister,handleSubmit, andformState: { errors }. registeris used to register inputs with RHF. You pass the input’snameand optional validation rules directly here or via a schema.handleSubmitwraps your submission logic.errorsobject directly contains validation errors.
// src/components/SimpleRHFForm.jsx
import React from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; // For Yup integration
import * as Yup from 'yup';
const schema = Yup.object({
email: Yup.string().email('Invalid email address').required('Required'),
password: Yup.string().min(6, 'Must be 6 characters or more').required('Required'),
}).required(); // .required() for the schema itself is good practice with yupResolver
const SimpleRHFForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(schema), // Integrate Yup validation
});
const onSubmit = data => {
alert(JSON.stringify(data, null, 2));
console.log('Form submitted:', data);
};
return (
<div style={{ border: '1px solid #ccc', padding: '20px', margin: '20px' }}>
<h3>Simple React Hook Form</h3>
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
{...register('email')} // Register input
/>
{errors.email && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.email.message}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
{...register('password')} // Register input
/>
{errors.password && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.password.message}</div>
)}
</div>
<button type="submit" style={{ marginTop: '20px', padding: '10px 20px' }}>Submit</button>
</form>
</div>
);
};
export default SimpleRHFForm;
// In your App.js:
// import SimpleRHFForm from './components/SimpleRHFForm';
// function App() { return <SimpleRHFForm />; }
// Don't forget to install:
// npm install react-hook-form yup @hookform/resolvers
Complex React Hook Form Example: User Profile with Nested Address and Dynamic Fields
Explanation:
- We use Zod for validation, which is often preferred for TypeScript projects due to its type inference capabilities.
registersyntax for nested objects isaddress.street.useFieldArrayis crucial for managing dynamic lists (like hobbies here), making it easy to add and remove items.
// src/components/ComplexRHFForm.jsx
import React from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; // For Zod integration
import { z } from 'zod'; // For validation
// Define Zod Schema
const schema = z.object({
fullName: z.string().max(50, 'Must be 50 characters or less').min(1, 'Full Name is required'),
email: z.string().email('Invalid email address').min(1, 'Email is required'),
age: z.number().min(18, 'Must be at least 18').refine(val => !isNaN(val), { message: 'Age must be a number' }),
address: z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid Zip Code').min(1, 'Zip Code is required'),
}),
hobbies: z.array(z.string()).min(1, 'Please select at least one hobby'), // Array validation
});
const ComplexRHFForm = () => {
const {
register,
handleSubmit,
control, // Needed for useFieldArray
formState: { errors },
} = useForm({
resolver: zodResolver(schema), // Integrate Zod validation
defaultValues: {
fullName: '',
email: '',
age: 0, // Zod number will error if ""
address: {
street: '',
city: '',
zipCode: '',
},
hobbies: [], // Initial empty array for useFieldArray
},
});
// For dynamic hobbies (checkboxes behave a bit differently,
// but let's imagine a text input list for true dynamic fields for useFieldArray example)
// For checkboxes, a simple state management or direct register with array value is often simpler.
// We'll simulate dynamic hobbies with an input for a new hobby.
const { fields, append, remove } = useFieldArray({
control,
name: "hobbies",
});
const hobbiesOptions = ['Reading', 'Gaming', 'Hiking', 'Cooking', 'Sports'];
const onSubmit = data => {
alert(JSON.stringify(data, null, 2));
console.log('Form submitted:', data);
};
return (
<div style={{ border: '1px solid #ccc', padding: '20px', margin: '20px' }}>
<h3>Complex React Hook Form</h3>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Basic Fields */}
<div>
<label htmlFor="fullName">Full Name</label>
<input
id="fullName"
type="text"
{...register('fullName')}
/>
{errors.fullName && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.fullName.message}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
{...register('email')}
/>
{errors.email && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.email.message}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="age">Age</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })} // Ensure age is treated as number
/>
{errors.age && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.age.message}</div>
)}
</div>
{/* Nested Address Fields */}
<fieldset style={{ border: '1px solid #eee', padding: '15px', marginTop: '20px' }}>
<legend>Address</legend>
<div>
<label htmlFor="address.street">Street</label>
<input
id="address.street"
type="text"
{...register('address.street')}
/>
{errors.address?.street && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.address.street.message}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="address.city">City</label>
<input
id="address.city"
type="text"
{...register('address.city')}
/>
{errors.address?.city && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.address.city.message}</div>
)}
</div>
<div style={{ marginTop: '15px' }}>
<label htmlFor="address.zipCode">Zip Code</label>
<input
id="address.zipCode"
type="text"
{...register('address.zipCode')}
/>
{errors.address?.zipCode && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.address.zipCode.message}</div>
)}
</div>
</fieldset>
{/* Dynamic Hobbies (using useFieldArray as a text input list) */}
<fieldset style={{ border: '1px solid #eee', padding: '15px', marginTop: '20px' }}>
<legend>Hobbies (Dynamic Text Inputs)</legend>
{fields.map((field, index) => (
<div key={field.id} style={{ marginBottom: '10px' }}>
<input
type="text"
{...register(`hobbies.${index}`)}
defaultValue={field.value} // important to set default value for useFieldArray
style={{ width: 'calc(100% - 70px)' }}
/>
<button type="button" onClick={() => remove(index)} style={{ marginLeft: '10px', background: '#f44', color: 'white', border: 'none', padding: '5px 10px' }}>Remove</button>
</div>
))}
<button type="button" onClick={() => append("")} style={{ marginTop: '10px', padding: '5px 10px' }}>Add Hobby</button>
{errors.hobbies && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.hobbies.message}</div>
)}
</fieldset>
{/* Checkbox Hobbies - simpler without useFieldArray unless initial values need managing */}
<fieldset style={{ border: '1px solid #eee', padding: '15px', marginTop: '20px' }}>
<legend>Hobbies (Checkboxes - simpler without useFieldArray)</legend>
{hobbiesOptions.map(hobby => (
<div key={hobby} style={{ marginBottom: '5px' }}>
<input
type="checkbox"
id={`checkbox-hobby-${hobby}`}
value={hobby}
{...register('hobbies')} // RHF will collect all checked values into an array
/>
<label htmlFor={`checkbox-hobby-${hobby}`} style={{ marginLeft: '5px' }}>{hobby}</label>
</div>
))}
{errors.hobbies && (
<div style={{ color: 'red', fontSize: '0.8em' }}>{errors.hobbies.message}</div>
)}
</fieldset>
<button type="submit" style={{ marginTop: '20px', padding: '10px 20px' }}>Submit</button>
</form>
</div>
);
};
export default ComplexRHFForm;
// In your App.js:
// import ComplexRHFForm from './components/ComplexRHFForm';
// function App() { return <ComplexRHFForm />; }
// Don't forget to install:
// npm install react-hook-form zod @hookform/resolvers
React Hook Form Error Cases and Solutions
Error Case 1: Validation doesn’t show up until submit.
- Problem: You want errors to appear as soon as a field is invalid (e.g., on blur or change), but they only show after submitting the form.
- Solution: Configure the
modeoption inuseForm.mode: 'onBlur'(default): Validates on blur and on submit.mode: 'onChange': Validates on every change (can cause more re-renders, but instant feedback).mode: 'onTouched': Validates once a field is “touched” and subsequently on change.- You can set this as an option in
useForm:const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), mode: 'onBlur', // Or 'onChange' });
Error Case 2: Dealing with custom components (e.g., a custom Dropdown or DatePicker).
- Problem: Your custom component doesn’t expose a native
refor doesn’t havename,onChange,valueprops in the way RHF expects forregister. - Solution: Use
Controllerfromreact-hook-form.Controlleracts as a wrapper that connects RHF to your custom component.import { Controller, useForm } from 'react-hook-form'; import DatePicker from 'react-datepicker'; // Example external component import "react-datepicker/dist/react-datepicker.css"; function MyForm() { const { control, handleSubmit } = useForm(); const onSubmit = data => console.log(data); return ( <form onSubmit={handleSubmit(onSubmit)}> <Controller control={control} name="myDatePicker" render={({ field }) => ( <DatePicker onChange={(date) => field.onChange(date)} selected={field.value} // Other props like onBlur, ref etc., can be passed from field /> )} /> <button type="submit">Submit</button> </form> ); }
Error Case 3: Performance issues with large forms (less common than Formik).
- Problem: Although RHF is known for performance, deeply nested
formStateor excessive use ofwatchcan sometimes cause re-renders. - Solution:
- Destructure
formState: Only destructure the properties you need ({ errors }, not{ ...formState }). This prevents re-renders for unused state changes. useWatch: UseuseWatchif you need to subscribe to a specific field’s value changes without re-rendering the entire form or a large portion of it.shouldUnregister: true: (Default behavior) Helps with performance by unregistering fields that are unmounted.
- Destructure
React Hook Form Pros & Cons
| Feature | Pros | Cons |
|---|---|---|
| Ease of Use | - Minimal boilerplate ({...register('fieldName')}). | - Initial setup can feel less intuitive for new React developers (ref-based, uncontrolled inputs). |
| - Simple API for most cases. | - Controller needed for controlled components (e.g., from UI libraries) adds a layer of complexity. | |
| Performance | - Highly performant by design (uncontrolled inputs, minimizes re-renders). | - More re-renders if using mode: 'onChange' or extensive watch without careful optimization. |
| - Fields update without re-rendering the whole form. | ||
| Validation | - Excellent integration with various validation libraries (Yup, Zod, Joi). | - Error messages need to be accessed from errors.fieldName.message manually. |
| - Native HTML validation can be leveraged. | ||
| Boilerplate | - Very low boilerplate, especially for simple inputs. | - Controller adds boilerplate for custom/controlled inputs. |
| Community | - Large, active community, excellent documentation. | |
| Typescript | - First-class TypeScript support (especially with Zod). |
3. Validation Libraries: Yup and Zod
Both Formik and React Hook Form don’t come with their own validation schemas; they integrate with external libraries. Yup and Zod are two of the most popular choices.
Yup
What it is: A schema builder for value parsing and validation. It’s very popular for its chainable, readable API.
Key Features:
- Schema-based validation: Define your validation rules in a structured schema object.
- Synchronous and Asynchronous validation: Supports both.
- Composable: You can reuse parts of your schema.
- Transformations: Can transform values (e.g.,
string().trim()).
When to use:
- If you’re comfortable with JavaScript and want a robust, widely used validation library.
- When working with Formik, as it has native integration.
- For projects without a strong TypeScript focus (though it works with TS).
Simple Yup Schema Example
import * as Yup from 'yup';
const simpleUserSchema = Yup.object({
email: Yup.string()
.email('Invalid email address')
.required('Email is required'),
password: Yup.string()
.min(6, 'Password must be at least 6 characters')
.required('Password is required'),
});
// Example usage (outside React for conceptual understanding)
try {
simpleUserSchema.validateSync({ email: 'test@example.com', password: 'abcde' }); // Throws error
} catch (error) {
console.log(error.errors); // Output: ["Password must be at least 6 characters"]
}
Complex Yup Schema Example
import * as Yup from 'yup';
const complexUserProfileSchema = Yup.object({
fullName: Yup.string()
.min(3, 'Full name must be at least 3 characters')
.max(50, 'Full name cannot exceed 50 characters')
.required('Full name is required'),
email: Yup.string()
.email('Invalid email address')
.required('Email is required'),
age: Yup.number()
.min(18, 'You must be at least 18 years old')
.max(120, 'Age cannot exceed 120')
.required('Age is required')
.typeError('Age must be a number'), // Important for number fields
address: Yup.object({
street: Yup.string().required('Street is required'),
city: Yup.string().required('City is required'),
zipCode: Yup.string()
.matches(/^\d{5}(-\d{4})?$/, 'Invalid Zip Code format (e.g., 12345 or 12345-6789)')
.required('Zip Code is required'),
}),
hobbies: Yup.array()
.of(Yup.string().min(2, 'Hobby name too short'))
.min(1, 'Please select at least one hobby')
.required('Hobbies are required'),
termsAccepted: Yup.boolean()
.oneOf([true], 'You must accept the terms and conditions')
.required('You must accept the terms and conditions'),
});
// Example usage
try {
complexUserProfileSchema.validateSync({
fullName: 'John Doe',
email: 'john.doe@example.com',
age: 25,
address: {
street: '123 Main St',
city: 'Anytown',
zipCode: '12345',
},
hobbies: ['Reading', 'Gaming'],
termsAccepted: true,
});
console.log('Validation successful!');
} catch (error) {
console.error('Validation errors:', error.errors);
}
Zod
What it is: A TypeScript-first schema declaration and validation library. Zod’s main selling point is its incredible TypeScript inference, allowing you to define your validation schema once and automatically derive types for your form data.
Key Features:
- TypeScript-first: Excellent type inference, automatically creating TypeScript types from your schemas.
- Runtime validation: Validates data at runtime.
- Composable: Schemas can be composed and reused.
- Transformations: Powerful
transformmethods. - Smaller bundle size compared to some other validators.
When to use:
- Highly recommended for TypeScript projects.
- When you want strong type safety from your validation schema.
- If you prefer a more functional approach to schema definition.
Simple Zod Schema Example
import { z } from 'zod';
const simpleUserSchema = z.object({
email: z.string().email('Invalid email address').min(1, 'Email is required'),
password: z.string().min(6, 'Password must be at least 6 characters').min(1, 'Password is required'),
});
// Infer type directly from schema
type SimpleUser = z.infer<typeof simpleUserSchema>;
// SimpleUser will be: { email: string; password: string; }
// Example usage (outside React for conceptual understanding)
try {
const parsed = simpleUserSchema.parse({ email: 'test@example.com', password: 'abcde' });
console.log(parsed);
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error.errors.map(err => err.message)); // Output: ["Password must be at least 6 characters"]
}
}
Complex Zod Schema Example
import { z } from 'zod';
const complexUserProfileSchema = z.object({
fullName: z.string()
.min(3, 'Full name must be at least 3 characters')
.max(50, 'Full name cannot exceed 50 characters'),
email: z.string()
.email('Invalid email address')
.min(1, 'Email is required'), // Use min(1) for required strings
age: z.number()
.min(18, 'You must be at least 18 years old')
.max(120, 'Age cannot exceed 120')
.int('Age must be an integer'), // Ensure it's an integer
address: z.object({
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
zipCode: z.string()
.regex(/^\d{5}(-\d{4})?$/, 'Invalid Zip Code format (e.g., 12345 or 12345-6789)'),
}),
hobbies: z.array(z.string())
.min(1, 'Please select at least one hobby'),
termsAccepted: z.boolean()
.refine(val => val === true, {
message: 'You must accept the terms and conditions',
}),
});
// Infer type for your entire form data
type ComplexUserProfile = z.infer<typeof complexUserProfileSchema>;
/*
ComplexUserProfile will be:
{
fullName: string;
email: string;
age: number;
address: {
street: string;
city: string;
zipCode: string;
};
hobbies: string[];
termsAccepted: boolean;
}
*/
// Example usage
try {
const parsed = complexUserProfileSchema.parse({
fullName: 'Jane Doe',
email: 'jane.doe@example.com',
age: 30,
address: {
street: '456 Oak Ave',
city: 'Sometown',
zipCode: '67890',
},
hobbies: ['Cooking'],
termsAccepted: true,
});
console.log('Validation successful!', parsed);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Validation errors:', error.errors.map(err => err.message));
}
}
Conclusion: Which one is better?
There’s no single “better” library; it depends on your project’s needs and your team’s preferences.
Choose React Hook Form if:
- Performance is your top priority. RHF’s focus on uncontrolled components and minimal re-renders makes it extremely fast.
- You prefer a more uncontrolled component approach or are comfortable with refs.
- You are working in a TypeScript environment and want excellent type inference with Zod.
- You want a leaner library with less boilerplate code for simple inputs.
- You need to handle complex dynamic fields (e.g., adding/removing lists of items) efficiently with
useFieldArray.
Choose Formik if:
- You prefer a more “all-in-one” solution where the library manages most of the form state for you.
- You prefer controlled components and explicit prop passing (
value,onChange,onBlur). - You’re already familiar with it, or your existing codebase uses it.
- You’re not heavily invested in TypeScript or prefer Yup for validation.
My recommendation for new projects (especially with TypeScript): Start with React Hook Form + Zod. Its performance, minimal re-renders, and superb TypeScript integration often lead to a more pleasant developer experience in the long run. However, Formik is also a perfectly valid and robust choice, especially for simpler forms or if you value its integrated state management.
Always try a simple example of both in your own environment to get a feel for their APIs before committing to one!