Forms in React can be tricky. As the number of input fields increases, you add third-party components, and then you need validation on top of that, things quickly become a state management nightmare.
React Hook Form is an elegant solution for managing forms in React. It provides a useForm hook that handles form state, field validation, error states, and much more. React Hook Form embraces uncontrolled inputs, minimizes unnecessary renders, and comes packed with features.
What we’ll build
We’ll create a reusable form abstraction on top of React Hook Form. This includes:
- A custom
useFormhook that wraps React Hook Form’suseFormhook - A
<Form />component for form structure - A reusable
<Input />component that displays validation errors - A
<FieldError />component for error messages
This abstraction will make forms simple to use while keeping them type-safe and performant.
Dependencies
We’ll need a handful of dependencies for this project:
- React (with TypeScript)
- react-hook-form
- @hookform/resolvers (helper library to resolve Zod schemas)
- zod (validation library)
About Zod
Zod is a TypeScript-first schema validation library with static type inference. You can declare a schema that defines the shape of the object you want to validate against.
For example, a person object schema can be defined as follows:
import { z } from 'zod'
const personSchema = z.object({ // field, its type and custom constraint with validation messages firstName: z.string().min(1, 'First Name must be at least 1 character long.'),})Install dependencies
Start by creating a fresh React project with TypeScript using Create React App or Vite (recommended).
Run the following command to install the dependencies:
yarn add react-hook-form zod @hookform/resolversOr with npm:
npm install react-hook-form zod @hookform/resolversCreating our custom useForm hook
Create a file form.tsx in your components folder.
// Function to resolve Zod schema we provideimport { zodResolver } from '@hookform/resolvers/zod'
// We'll fully type the `<Form />` component by providing component propsimport { ComponentProps } from 'react'
import { // We import useForm hook as useHookForm useForm as useHookForm, // TypeScript types of useHookForm props UseFormProps as UseHookFormProps, // Context provider for our form FormProvider, // Return type of useHookForm hook UseFormReturn, // TypeScript type of form's field values FieldValues, // Type of submit handler event SubmitHandler, // Hook that returns errors in current instance of form useFormContext,} from 'react-hook-form'
// Type of Zod schemaimport { ZodSchema, z } from 'zod'
// We provide an additional option that will be our Zod schemainterface UseFormProps<T extends ZodSchema<any>> extends UseHookFormProps<z.infer<T>> { schema: T}
export const useForm = <T extends ZodSchema<any>>({ schema, ...formConfig}: UseFormProps<T>) => { return useHookForm({ ...formConfig, resolver: zodResolver(schema), })}There’s a lot happening here. We created an interface for the useForm props. The props extend the existing React Hook Form props, but with an additional difference: we provide a Zod schema to it as well.
This ensures the returned values from the useForm hook are correctly typed according to the Zod schema. We’ll see how to use it in a moment.
Creating the <Form /> component
Now that we’ve created the useForm hook, let’s create a <Form /> component that uses the values returned from useForm:
// We omit the native `onSubmit` event in favor of `SubmitHandler` event// The beauty of this is that the values returned by the submit handler are fully typedinterface FormProps<T extends FieldValues = any> extends Omit<ComponentProps<'form'>, 'onSubmit'> { form: UseFormReturn<T> onSubmit: SubmitHandler<T>}
export const Form = <T extends FieldValues>({ form, onSubmit, children, ...props}: FormProps<T>) => { return ( <FormProvider {...form}> {/* The `form` passed here is the return value of useForm() hook */} <form onSubmit={form.handleSubmit(onSubmit)} {...props}> <fieldset // We disable form inputs when we are submitting the form // A tiny detail that is missed a lot of times disabled={form.formState.isSubmitting}> {children} </fieldset> </form> </FormProvider> )}Creating the error component
We’ll create a component to display field errors. This will render a small <span /> next to the respective <Input /> field:
export function FieldError({ name }: { name?: string }) { // The useFormContext hook returns the current state of the form const { formState: { errors }, } = useFormContext()
if (!name) return null
const error = errors[name]
if (!error) return null
return <span>{error.message as string}</span>}Creating the reusable Input component
Now that we have our form hook, form component, and error component, we need a reusable input field.
Create a file named input.tsx with the following snippet:
import { ComponentProps, forwardRef } from 'react'import { FieldError } from './form'
interface InputProps extends ComponentProps<'input'> { label: string}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( { label, type = 'text', ...props }, ref,) { return ( <div> <label>{label}</label> <input type={type} ref={ref} {...props} /> <FieldError name={props.name} /> </div> )})We use forwardRef here. Using forwardRef in React gives the child component a reference to a DOM element created by its parent component. This allows the child to read and modify that element anywhere it is being used.
How to use
Suppose you have a signup form with four fields: first name, username, email, and password. Pretty standard stuff, right? Let’s see how this abstraction makes our work easy:
// Make sure to import it properlyimport { Form, useForm } from '../form/form'import { z } from 'zod'
// Let's declare our validation and shape of form// Zod takes care of email validation, and it also supports custom regexconst signUpFormSchema = z.object({ firstName: z.string().min(1, 'First Name must be at least 1 character long!'), username: z .string() .min(1, 'Username must be at least 1 character long!') .max(10, 'Consider using a shorter username.'), email: z.string().email('Please enter a valid email address.'), password: z .string() .min(6, 'Please choose a longer password') .max(256, 'Consider using a shorter password'), // Add your fancy password requirements here})
export function SignUpForm() { const form = useForm({ schema: signUpFormSchema, })
return ( <Form form={form} onSubmit={(values) => { console.log('Form submitted with:', values) // Handle form submission }}> <Input label="Your first name" type="text" placeholder="John" {...form.register('firstName')} />
<Input label="Choose username" type="text" placeholder="im_john_doe" {...form.register('username')} />
<Input label="Email Address" type="email" placeholder="you@example.com" {...form.register('email')} />
<Input label="Password" type="password" placeholder="Your password (min 6)" {...form.register('password')} />
<button type="submit">Submit</button> </Form> )}Note that we haven’t written a single if-else statement, any useRef or useState for that matter to track error state, validation state, or form state.
I kept it free of any styling so we can focus on what matters.
Using this pattern means fewer unnecessary re-renders of components, better performance, and a cleaner codebase.
Try it out
You can try this pattern on StackBlitz.
Benefits
We’ve seen how easy it is to abstract away form logic to make it simple to use while keeping it as safe as possible. Here are the key benefits:
- Type safety: Full TypeScript support with inferred types from Zod schemas
- Performance: Minimal re-renders thanks to uncontrolled inputs
- Validation: Schema-based validation with clear error messages
- Reusability: Components can be used across different forms
- Developer experience: Less boilerplate, more productivity
Conclusion
React Hook Form combined with Zod provides a powerful, type-safe way to handle forms in React. By creating a small abstraction layer, we can make forms even easier to work with while maintaining all the benefits of these libraries.
If you already use React Hook Form, I’d love to hear about any specific patterns you follow. Feel free to reach out!
Follow me on X for more updates.