When it came time to introduce a form validation framework to my previous company's app, we actually settled on TanStack Form. We took one swing at it, and immediately felt that the syntax was not for us. Sorry Tanner.
So we went back to the drawing board, and React Hook Form it was! One of us made a proof of concept by integrating it with our login form. It worked and it looked good, so the decision was made to continue using the library.
What we overlooked
What no one realized at the time was that our login and signup forms were the oldest forms in the entire app. Hence, they were the only ones not using our reusable <FormInput>
components, which we used everywhere else. They looked a little bit like this:
// OldDeprecatedUncoolLoserFormInput.tsx
const FormInput: React.FC<FormInputProps> = ({
type,
inputName,
labelText,
isRequired,
isDisabled,
value,
onChange,
}) => {
return (
<div className="flex flex-col gap-1">
<label htmlFor={inputName} className="text-xs">{labelText}{isRequired && '*'}</label>
<input type={type} id={inputName} name={inputName}
className="input input-sm input-bordered"
value={value}
onChange={onChange}
required={isRequired}
disabled={isDisabled}
/>
</div>
);
}
It's no surprise that we couldn't use these as-is with RHF. Below is a comparison of the two:
{/* Here's how we used our old form inputs */}
<FormInput
type="date"
inputName="arbitraryFieldName"
labelText="arbitrary field name"
value={someFormData.arbitraryFieldName}
onChange={someOnChangeInputFunction}
/>
{/* ..and here's an example from the RHF docs. When working with RHF, you need to pass in register to the input tag */}
<form onSubmit={handleSubmit(onSubmit)}>
<input defaultValue="test" {...register("example")} />
</form>
Our forms, our onChange handlers for the forms' inputs, and the inputs themselves weren't built to work with RHF. Some major refactoring was in order. This responsibility fell onto me. Surprisingly, there were very few guides that actually went into solving this problem. Do people just not make reusable form inputs with RHF?
As we go on to mention in the references, this guide has adapted a lot from Keionne Derousselle's blog post, which was critical in solving this problem when I first encountered it. This guide is meant to be a more condensed (and slightly updated) version to its predecessor.
What we want to achieve
- Create a new, reusable form input component that works with RHF
- Disentangle inputs from the new reusable form input component. This means creating an
<Input>
component separate from<FormInput>
- At the same time, we want a more robust
<FormInput>
that can show errors and accept class names for styling - We also want to demonstrate how one might tie RHF, RHF resolvers and react-query together.
Skipping to the end
If you're in a hurry and want a bird's eye view of the code without any of the explanations, you can go straight to the GitHub repository for this project.
Other tools we'll work with
Besides RHF and TypeScript, we'll be working with Vite, Tailwind CSS, react-query
, zod
, and classnames
.
Project setup
$ npm create vite@latest reusable-rhf-form-inputs -- --template react-ts
$ cd reusable-rhf-form-inputs && npm install
$ npm install react-hook-form @hookform/error-message
@hookform/resolvers @tanstack/react-query zod classnames
# remove boilerplate files that we do not need
$ cd src && rm -rf assets/ App.css index.css && cd ..
You can opt to follow the regular installation instead if you'd like, but for this guide we'll be using the tailwind CDN installation:
<!-- slot this into your <head> at src/index.html -->
<script src="https://cdn.tailwindcss.com"></script>
Let's edit some starter files as well:
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App'
// used later for react-query
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
)
// src/App.tsx
export default function App() {
return (
// confirm that tailwind is installed
<h1 className="text-indigo-500">Hello world!</h1>
)
}
We're ready to start! Let's carry on.
The <Input>
Component
We start by defining a generic input component that isn't necessarily tied to an RHF form. We'll create our <FormInput>
later based on this.
import { forwardRef, DetailedHTMLProps, InputHTMLAttributes } from 'react';
import classNames from 'classnames';
export type InputType = 'text' | 'email' | 'date' | 'time' | 'datetime' | 'number';
export type InputProps = {
id: string;
name: string;
label: string;
type?: InputType;
className?: string;
placeholder?: string;
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;
export const Input: React.FC<InputProps> = forwardRef<HTMLInputElement, InputProps>((
{
id,
name,
label,
type = 'text',
className = '',
placeholder,
...props
},
ref
) => {
return (
<input
id={id}
ref={ref}
name={name}
type={type}
aria-label={label}
placeholder={placeholder}
// change this to however inputs should be styled in your project
className={classNames(
'block w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 placeholder-gray-400 transition',
className
)}
{...props}
/>
);
});
We can change our App.tsx
to show the changes we've made so far:
// src/App.tsx
import { Input } from "./components/Input";
export default function App() {
return (
<div className="w-96 p-4">
<h1 className="text-indigo-500 mb-4">Form with basic inputs</h1>
<form className="flex flex-col gap-2">
<Input id="first_name" name="first_name" label="first_name" />
<Input id="employee_number" name="employee_number" label="employee_number" type="number" />
<button type="submit" className="rounded-md text-white bg-blue-500 hover:bg-blue-600 p-2">Submit</button>
</form>
</div>
)
}
You should be able to run npm run dev
and see our inputs in action.
<FormInput>
and the types that come with it
Here's our <FormInput>
component:
// src/components/FormInput.tsx
import {
Path,
UseFormRegister,
FieldValues,
DeepMap,
FieldError,
} from "react-hook-form";
import { ErrorMessage } from "@hookform/error-message";
import { Input, InputProps } from "./Input";
export type FormInputProps<TFormValues extends FieldValues> = {
name: Path<TFormValues>;
outerDivClassName?: string;
innerDivClassName?: string;
inputClassName?: string;
register?: UseFormRegister<TFormValues>;
errors?: Partial<DeepMap<TFormValues, FieldError>>;
isErrorMessageDisplayed?: boolean;
} & Omit<InputProps, 'name'>;
const FormInput = <TFormValues extends FieldValues>({
outerDivClassName,
innerDivClassName,
inputClassName,
name,
label,
register,
errors,
isErrorMessageDisplayed = true,
...props
}: FormInputProps<TFormValues>): JSX.Element => {
return (
<div className={outerDivClassName}>
<div className={innerDivClassName}>
<label htmlFor={name} className="text-xs">{label}{props.required && '*'}</label>
<Input
name={name}
label={label}
className={inputClassName}
{ ...props }
{ ...(register && register(name)) }
/>
</div>
{ isErrorMessageDisplayed && <ErrorMessage
errors={errors}
name={name as any}
render={({ message }) => (
<p className="text-xs text-deya-red-500">
{message}
</p>
)}
/> }
</div>
);
}
export default FormInput;
There's certainly a lot to parse from FormInputProps
! Let's take it one step at a time.
The fields below are fairly simple and self-explanatory. We'll keep the discussion on the other parts.
export type FormInputProps = {
// className props for styling different parts of the FormInput
outerDivClassName?: string;
innerDivClassName?: string;
inputClassName?: string;
// use cautiously, in cases where we don't want to display the error message, such as if you want to display
// all error messages in a central area in your form, or if there are no errors possible
// e.g. a checkbox where false and true are both valid
isErrorMessageDisplayed?: boolean;
} & Omit<InputProps, 'name'>; // intersection with InputProps EXCEPT name
Some of our props are going to depend on what our form actually looks like; hence, we need to pass in a form values generic type. This type TFormValues
also must inherit from the type FieldValues
to be compatible with the register
prop later on.
export type FormInputProps<TFormValues extends FieldValues> = {
// ...
} & Omit<InputProps, 'name'>;
// FieldValues type for reference
type FieldValues = {
[x: string]: any;
}
Case in point, our name
prop, which curiously isn't of type String
. If you're familiar with RHF, you might be familiar with syntax for getting values like root.arrayField.[0].fieldName
or the more readable root.employees.[0].first_name
. Similarly, our name
must represent the exact path to the field, given TFormValues.
export type FormInputProps<TFormValues extends FieldValues> = {
name: Path<TFormValues>;
// ...
} & Omit<InputProps, 'name'>;
Finally, we have the register
prop to register our input to a form, and our errors
prop.
export type FormInputProps<TFormValues extends FieldValues> = {
// ...
register?: UseFormRegister<TFormValues>;
errors?: Partial<DeepMap<TFormValues, FieldError>>;
// ...
} & Omit<InputProps, 'name'>;
DeepMap
is a type under RHF that recursively maps the keys of an object (or form) to some value. This means that it would change your hypothetical form this way:
const formValues = {
user: {
name: '',
address: {
street: '',
city: ''
}
}
};
const errors = {
user: {
name: SomeFieldErrorOrNothing,
address: {
street: { message: "Street is required" },
city: SomeFieldErrorOrNothing,
}
}
};
But as you may have noticed from that snippet, we won't always have field errors for every part of the form; hopefully, we won't have errors at all! This is where Partial
comes in, which trims things down to only what's necessary:
const errors = {
user: {
address: {
street: { message: "Street is required" },
}
}
};
One notable difference between this guide and its predecessor is that we forego the usage of a rules
property that would be passed to our FormInput. This is because we will make use of an RHF resolver on the form level instead.
...and that's it! We now have a universal <FormInput>
component that we can use anywhere within our app.
Prerequisites for the form
Before we move on to creating a form and tying all of our moving parts together, we have some boxes to tick off:
Creating some types
// src/types.ts
export type EmployeeForm = {
first_name: String;
employee_number: Number;
}
export type EmployeeCreateRequest = {
employee: EmployeeForm;
}
export const defaultEmployeeFormValues = {
first_name: '',
employee_number: 0,
}
Creating a hook to mock db operations
// src/hooks.ts
import { useMutation } from "@tanstack/react-query"
import { EmployeeCreateRequest } from "./types"
const createEmployee = async(req: EmployeeCreateRequest) => {
// normally you'd want to make an axios request to your backend API here
console.log(req);
}
export const useCreateEmployee = () => {
return useMutation({
mutationFn: (req: EmployeeCreateRequest) => createEmployee(req)
})
}
Writing validation
// src/validation.ts
import * as z from "zod";
export const createEmployeeSchema = z.object({
first_name: z.string().min(1).max(20),
employee_number: z.coerce.number().refine(val => Number(val) >= 0 , 'The number must not be less than 0'),
});
Putting everything together
With all that being done, we can finally write our form:
// src/App.tsx
import { DeepMap, FieldError, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createEmployeeSchema } from "./validation";
import { EmployeeCreateRequest, EmployeeForm, defaultEmployeeFormValues } from "./types";
import { useCreateEmployee } from "./hooks";
import FormInput from "./components/FormInput";
export default function App() {
// you can use variable names like `register` as-is, but this is more explicit
// and will come in handy whenever you need more than 1 form in your component
const {
register: createEmployeeFormRegister,
handleSubmit: createEmployeeFormHandleSubmit,
getValues: createEmployeeFormGetValues,
formState: createEmployeeFormState,
} = useForm<EmployeeForm>({
defaultValues: defaultEmployeeFormValues,
mode: 'onBlur',
resolver: zodResolver(createEmployeeSchema)
});
// we must ensure that errors are coerced to the right type
const { errors: createEmployeeFormErrors } = createEmployeeFormState as {
errors: Partial<DeepMap<EmployeeForm, FieldError>>
};
// simulate a request to the DB on submit
const createEmployeeMutation = useCreateEmployee();
const onCreateEmployeeFormSubmit = createEmployeeFormHandleSubmit(() => {
const payload: EmployeeCreateRequest = { employee: createEmployeeFormGetValues() }
createEmployeeMutation.mutate(payload);
})
return (
<div className="w-96 p-4">
<h1 className="text-indigo-500 mb-4">Form with basic inputs</h1>
<form onSubmit={onCreateEmployeeFormSubmit} className="flex flex-col gap-2">
<FormInput<EmployeeForm> // don't forget to pass in TFormValues to your FormInput!
id="first_name"
name="first_name" // note that here you might pass something like employees.[0].first_name as well
label="First Name"
register={createEmployeeFormRegister}
errors={createEmployeeFormErrors}
required={true}
/>
<FormInput<EmployeeForm>
type="number"
id="employee_number"
name="employee_number"
label="Employee Number"
register={createEmployeeFormRegister}
errors={createEmployeeFormErrors}
required={true}
/>
<button type="submit" className="rounded-md text-white bg-blue-500 hover:bg-blue-600 p-2">Submit</button>
</form>
</div>
)
}
That should be it! You can expect error messages to show right underneath the form inputs. Otherwise, if your form is valid, submitting should log its contents to the console.
Cheers!
References
- Keionne Derousselle's blog post was the inspiration for this guide, and a lot of what I say here is taken directly from what I learned from their post. None of this would have been possible without them--literally, I couldn't find any other guides that were helpful to me at the time--if you're here looking for answers to a problem you have, you might have come across their post already. If you haven't, then do give the post a read!
-
Official documentation for
react-hook-form
Top comments (0)