A refreshed look is coming your way soon
Logo
Overview
Build forms that don't make you cry

Build forms that don't make you cry

June 15, 2025
6 min read

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:

  1. A custom useForm hook that wraps React Hook Form’s useForm hook
  2. A <Form /> component for form structure
  3. A reusable <Input /> component that displays validation errors
  4. 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:

Terminal window
yarn add react-hook-form zod @hookform/resolvers

Or with npm:

Terminal window
npm install react-hook-form zod @hookform/resolvers

Creating our custom useForm hook

Create a file form.tsx in your components folder.

// Function to resolve Zod schema we provide
import { zodResolver } from '@hookform/resolvers/zod'
// We'll fully type the `<Form />` component by providing component props
import { 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 schema
import { ZodSchema, z } from 'zod'
// We provide an additional option that will be our Zod schema
interface 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 typed
interface 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 properly
import { 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 regex
const 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.