The great react-hook-form library's documentation contains a way to write smart form components. A smart form component is a React component, representing a field in a form, which is react-hook-form aware.
In this post I show my own twist on this pattern which makes it type safe when using TypeScript.

Like most form libraries react-hook-form decouples the look and feel of the from keeping track of the forms state.
Form state are things like: the values the user entered, if there are any errors, whether or not the form has been submitted etc etc.
The documentation of react-hook-form will show examples like the one below:
<input {...register("name")} />This gives you a very basic HTML <input> field. It does not have a <label> or any place to show error messages. Last but not least: the <input>'s appearance will leave much to be desired.
So you will want to write your own <Input/> component, which shows a label and error messages, and which looks beautiful.
But how to integrate it with react-hook-form?
The way I integrate my components with react-hook-form is by passing the register function as a prop like so:
<Input register={() => form.register('name')} label="Name*" />This way TypeScript can give typesafety, and most importantly auto complete:

The trick is that we call form.register() at the location of the useForm, at this point TypeScript knows exactly which fields the form has!
This is an annotated example of the Input component:
import { ErrorMessage } from '@hookform/error-message';
import { UseFormRegisterReturn, useFormState } from 'react-hook-form';
type Props = {
/**
* A `register` method that when called should return the result
* of calling `register()` from a `UseFormReturn`.
*/
register(): UseFormRegisterReturn;
/**
* The label of the `<input>` element.
*/
label: string;
};
/**
* The Input component is a "react-hook-form" aware `<input>`
* element.
*/
export function Input({ register, label }: Props) {
/*
Call `register` from the props which returns a
`UseFormReturn`. At this point the field is added
onto the form.
The `field` is an object that contains the value,
onChange, onBlur etc etc.
Later on we spread this into an `<input>` element.
*/
const field = register();
// Get all form errors
const { errors } = useFormState();
// Get the errors for this particular field.
const fieldErrors = errors[field.name];
const hasError = !!fieldErrors;
// Generate ids for accessibility
const id = useId();
const errorId = useId();
return (
<div>
<label htmlFor={id}>
<span
className={hasError ? 'error' : 'valid'}
>
{label}
</span>
<input
{/*
Spread the `field` so the onChange, onBlur and
value props are set!
*/}
{...field}
className="CSS classes here"
aria-invalid={hasError}
aria-describedby={errorId}
/>
</label>
<ol id={errorId} className='error'>
<ErrorMessage
errors={errors}
name={field.name}
render={({ message }) => (
<li>{message}</li>
)}
/>
</ol>
</div>
);
}Compare and contrast it here with the approach of the react-hook-form docs. As you can see it is written in JavaScript, and therefore does not provide any type safety.
Since the Input uses the useFormState hook we need to provide a FormProvider, so the Input can get to the forms data. If we do not do this we get this error:
useFormState.ts:49 Uncaught TypeError: Cannot read properties of null (reading 'control')
at Input (Input.tsx:38:34)The fix is simply wrapping the <form> in a FormProvider:
import { FormProvider, useForm } from 'react-hook-form';
export function FormExample() {
const form = useForm({
// more here
});
return (
{/* Providing a FormProvider so the <Input> works */}
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit((form) => onSubmit(form))}
>
<Input
register={() => form.register('name')} label="Name*"
/>
</form>
</FormProvider>
);
}This way the useFormState hook in our Input component can access all relevant form data.
Here is a complete example of one of my forms including zod for validation:
import { zodResolver } from '@hookform/resolvers/zod';
import { FormProvider, useForm } from 'react-hook-form';
import z from 'zod/v4';
import {
ImageInput,
Input,
SubmitButton,
Textarea
} from '../../../../../../components';
export type PokemonFormData = z.infer<typeof pokemonSchema>;
type Props = {
values: PokemonFormData;
onSubmit: (form: PokemonFormData) => void;
};
const pokemonSchema = z.object({
id: z.number(),
name: z
.string()
.min(1, 'Name is a required field')
.max(50, 'Name must be less than 50 characters'),
description: z
.string()
.min(1, 'Description is a required field')
.max(1024, 'Description must be less than 1024 characters'),
sprite: z.union(
[
z.string().min(1, 'Sprite is a required field'),
z.file('Sprite is a required field')
],
'Sprite is a required field'
),
});
export function PokemonHookForm({ values, onSubmit }: Props) {
const form = useForm({
mode: 'onTouched',
reValidateMode: 'onChange',
resolver: zodResolver(pokemonSchema),
values
});
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit((form) => onSubmit(form))}
>
<Input
register={() => form.register('name')} label="Name*"
/>
<Textarea
register={() => form.register('description')}
label="Description*"
/>
<ImageInput
register={() => form.register('sprite')}
label="Sprite*"
/>
<SubmitButton />
</form>
</FormProvider>
);
}The Textarea and ImageInput basically follow the same script as the Input.
The Textarea component simply wraps a <textarea> instead of an <input>
The ImageInput component wraps an <input type="file">
I think my solution is pretty neat... yes it requires slightly more code when you want to use the Input. But I think the TypeScript trade-off is well worth it.