Handling forms manually is not difficult in React. Keeping all of the code consistent in your project might be quite a hassle, though. There are many ways in which we can tackle the state of our forms and validation. We can expect the approach of our teammates to differ slightly. To manage our forms better, we look into the React Hook Form library that has gained quite a lot of traction lately.
In the past, we’ve covered Formik on this blog. We’ve also done it with the help of hooks API. This time, we look into how we can do similar things with React Hook Form. The knowledge from using Formik will come in handy because this time, we also use Yup for validation. We also point out some similarities and differences between Formik and React Hook Form.
Introducing React Hook Form
To start, we need to install the library. Since both Formik and React Hook Form are built with TypeScript, we don’t need any additional packages.
1 |
npm install react-hook-form |
When creating forms with TypeScript, the first thing is to create an interface describing our data. Although we could omit it, we would lose many benefits that React Hook Form has to offer.
1 2 3 4 5 |
interface RegistrationFormData { email: string; password: string; passwordConfirmation: string; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import React, { useCallback } from 'react'; import { useForm } from 'react-hook-form'; import RegistrationFormData from './RegistrationFormData'; const RegistrationForm = () => { const { register, handleSubmit } = useForm<RegistrationFormData>(); const onSubmit = useCallback((formValues: RegistrationFormData) => { console.log(formValues); }, []); return ( <form onSubmit={handleSubmit(onSubmit)}> <input name="email" type="email" ref={register} /> <input name="password" type="password" ref={register} /> <input name="passwordConfirmation" type="password" ref={register} /> <button type="submit"> Save </button> </form> ) } export default RegistrationForm; |
There a few notable things happening above. The most important is the register function. With it, we can register inputs and let the React Hook Form handle them. For it to be successful, we need to make sure that the names of our inputs match our interface.
The second crucial thing is the handleSubmit method. Let’s look under the hood of the React Hook Form and inspect it closely.
1 2 3 4 |
export declare type SubmitHandler<TFieldValues extends FieldValues> = ( data: UnpackNestedValue<TFieldValues>, event?: React.BaseSyntheticEvent ) => void | Promise<void>; |
1 2 3 4 |
handleSubmit: <TSubmitFieldValues extends FieldValues = TFieldValues>( onValid: SubmitHandler<TSubmitFieldValues>, onInvalid?: SubmitErrorHandler<TFieldValues> ) => (e?: React.BaseSyntheticEvent) => Promise<void>; |
Above there are quite a few generic types. If you want to know more about them, check out TypeScript Generics. Discussing naming conventions and More advanced types with TypeScript generics
We can see that handleSubmit, as an argument, expects a function that handles the data of the form. Once the user submits the form, React Hook Form calls our submit handler.
Striving for clean code, I suggest creating custom React hooks. If you want to read more about it, check out JavaScript design patterns #3. The Facade pattern and applying it to React Hooks.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import { useForm } from 'react-hook-form'; import RegistrationFormData from './RegistrationFormData'; import { useCallback } from 'react'; function useRegistrationForm() { const { register, handleSubmit } = useForm<RegistrationFormData>(); const onSubmit = useCallback((formValues: RegistrationFormData) => { console.log(formValues); }, []); return { register, onSubmit: handleSubmit(onSubmit) } } |
Moving the logic of the form into a custom hook helps us to get closer to the separation of concerns.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import React from 'react'; import useRegistrationForm from './useRegistrationForm'; const RegistrationForm = () => { const { register, onSubmit } = useRegistrationForm(); return ( <form onSubmit={onSubmit}> <input name="email" type="email" ref={register} /> <input name="password" type="password" ref={register} /> <input name="passwordConfirmation" type="password" ref={register} /> <button type="submit"> Save </button> </form> ) } |
Validation with Yup
By default, the React Hook Form library utilizes the native HTML form validation. For a variety of reasons, we might want to implement something fancier. Both React Hook Form and Formik encourage the use of the Yup library.
1 |
npm install @hookform/resolvers @types/yup yup |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
import { useForm } from 'react-hook-form'; import RegistrationFormData from './RegistrationFormData'; import { useCallback, useMemo } from 'react'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; function useRegistrationForm() { const validationSchema = useMemo(() => ( yup.object().shape({ email: yup.string().email().required('Email is required'), password: yup.string().required('Password is requred'), passwordConfirmation: yup.string() .test({ name: 'password-confirmation', message: 'Passwords need to match', test: function () { const { password, passwordConfirmation } = this.parent; if (password && passwordConfirmation !== password) { return false; } return true; } }) }) ), []) const { register, handleSubmit, errors } = useForm<RegistrationFormData>({ resolver: yupResolver(validationSchema) }); const onSubmit = useCallback((formValues: RegistrationFormData) => { console.log(formValues); }, []); return { register, errors, onSubmit: handleSubmit(onSubmit) } } |
There are a lot of possibilities when it comes to the Yup library. For a full list, check out the documentation.
As long as there are some errors, the React Hook Form library does not call the onSubmit handler. We can also access the error messages through the errors object.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import React from 'react'; import useRegistrationForm from './useRegistrationForm'; const RegistrationForm = () => { const { register, onSubmit, errors } = useRegistrationForm(); return ( <form onSubmit={onSubmit}> <input name="email" type="email" ref={register} /> <p>{errors.email?.message}</p> <input name="password" type="password" ref={register} /> <p>{errors.password?.message}</p> <input name="passwordConfirmation" type="password" ref={register} /> <p>{errors.passwordConfirmation?.message}</p> <button type="submit"> Save </button> </form> ) } |
Accessing the context of the form
For more advanced forms, we might need to access the form properties outside of the submit handler. The useForm returns the formState, but passing it through the component props might not be the cleanest approach.
The solution to the above issue is the FormProvider and the useFormContext.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { useForm } from 'react-hook-form'; import RegistrationFormData from './RegistrationFormData'; import { useCallback } from 'react'; function useRegistrationForm() { const methods = useForm<RegistrationFormData>(); const onSubmit = useCallback((formValues: RegistrationFormData) => { console.log(formValues); }, []); return { ...methods, onSubmit: methods.handleSubmit(onSubmit) } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import React from 'react'; import useRegistrationForm from './useRegistrationForm'; import { FormProvider } from 'react-hook-form'; import AddressForm from './AddressForm'; const RegistrationForm = () => { const { register, onSubmit, errors, ...methods } = useRegistrationForm(); return ( <FormProvider register={register} errors={errors} {...methods}> <form onSubmit={onSubmit}> <input name="email" type="email" ref={register} /> <p>{errors.email?.message}</p> <input name="password" type="password" ref={register} /> <p>{errors.password?.message}</p> <input name="passwordConfirmation" type="password" ref={register} /> <p>{errors.passwordConfirmation?.message}</p> <AddressForm /> <button type="submit"> Save </button> </form> </FormProvider> ) } |
After we inject the context using the FormProvider, we can start using it with the useFormContext hook.
To watch the value in real-time, we need to use the watch method provided by the React Hook Form library.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import React from 'react'; import { useFormContext } from 'react-hook-form'; import RegistrationFormData from "../RegistrationFormData"; const AddressForm = () => { const { register, watch } = useFormContext<RegistrationFormData>(); const isAddressProvided = watch('isAddressProvided'); return ( <div> <input type='checkbox' name='isAddressProvided' ref={register} /> { isAddressProvided && ( <> <input name="city" ref={register} /> <input name="street" ref={register} /> </> ) } </div> ) } |
Providing default values
An important aspect of managing forms is handling the initial state. When dealing with initial values that are known on the first render, we can use the defaultValues property.
1 2 3 4 5 6 7 |
const { register, handleSubmit } = useForm<RegistrationFormData>({ defaultValues: { email: 'test@email.com', password: '', passwordConfirmation: '' } }); |
A very common case is when we want to use data pulled from the API as the initial values of our form. In the documentation, we can find that the defaultValues is cached at the first render within the custom hook. If you want to reset the defaultValues, you should use the reset API.
The above means that we need to use the reset function provided by React Hook Form to use the initial values that we access asynchronously.
1 2 3 4 5 |
interface User { id: number; name: string; email: string; } |
I like to keep data fetching somewhat separate from the form management. To achieve that, we might pass a callback to our hook that deals with the data fetching.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import { useCallback, useEffect, useState } from 'react'; import User from './User'; function useUserProfileData( userId: number, handleDefaultValues: (userData: User) => void ) { const [user, setUser] = useState<User>(); const [isLoading, setIsLoading] = useState(true); const fetchUser = useCallback((userId) => { return fetch(`https://jsonplaceholder.typicode.com/users/${userId}`) .then((response) => response.json()) .then(userData => { setUser(userData); return userData; }) }, [setUser]); useEffect(() => { setIsLoading(true); fetchUser(userId) .then((userData: User) => { setIsLoading(false); handleDefaultValues(userData); }) }, [fetchUser, userId, setIsLoading, handleDefaultValues]) return { user, fetchUser, isLoading } } |
To separate the logic of the form, I also create a custom hook for it.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import { useCallback } from 'react'; import { useForm } from 'react-hook-form'; import UserProfileFormData from './UserProfileFormData'; import User from './User'; import useUserProfileData from './useUserProfileData'; function useUserProfileForm( userId: number ) { const { handleSubmit, register, reset } = useForm<UserProfileFormData>(); const handleDefaultValues = useCallback((user: User) => { const defaultValues: UserProfileFormData = { name: user?.name || '', email: user?.email || '' } reset(defaultValues); }, [reset]); const { user, isLoading } = useUserProfileData(userId, handleDefaultValues); const onSubmit = useCallback((formValues: UserProfileFormData) => { console.log(user?.id, formValues); }, [user]); return { register, isLoading, onSubmit: handleSubmit(onSubmit) } } |
We could also call the useEffect hook in useUserProfileForm to listen for the incoming data. This way, we wouldn’t have to pass a callback to useUserProfileData.
One important thing is to somehow handle the isLoading variable. In this simple example, we hide the form until we finish fetching the necessary data.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import React, { FunctionComponent } from 'react'; import useUserProfileForm from './useUserProfileForm'; interface Props { userId: number; } const UserProfileForm: FunctionComponent<Props> = ({ userId }) => { const { register, onSubmit, isLoading } = useUserProfileForm(userId); if (isLoading) { return <div>Loading...</div> } return ( <form onSubmit={onSubmit}> <> <input name="email" type="email" ref={register} /> <input name="name" ref={register} /> </> <button type="submit"> Save </button> </form> ) } |
On the contrary, Formik has the initialValues property and can automatically reinitialize the form when it changes.
Summary
In this article, we’ve learned quite a few things about how to use React Hook Form with TypeScript. Aside from the bare basics, we’ve learned how to validate the form using the Yup library. We’ve also learned how to deal with the initial value of the form with asynchronous data. Another thing that might come in handy is accessing the context of the form through the useFormContext hook.
What’s your view on the React Hook Form library? Would you use it instead of Formik?
thank you very much.. I’ve been using custom hook and since I decided test it for the first time I realized that was an amazing decision. The code achieves the approach of separation of concerns and looks like more elegant and easy to test.