Dynamic forms with React Hook Form. π
Franklin Martinez
Posted on March 10, 2023
This time, we will again create dynamic forms but now with the help of the react-hook-form library.
Note π£: You need to have knowledge in Typescript to follow this tutorial, as well as React JS.
You might be interested in this article, where we also do the same as in this post, but using the Formik library. π
Dynamic forms with Formik and React JS. π
Franklin Martinez γ» Oct 28 '22
Β
Table of contents.
π Technologies to be used.
π Creating the project.
π First steps.
π Creating the form object.π Creating the typing for the inputs.
π Now we create the form object with the help of the typing.π Creating the validation schema for our form.
π Function to generate the inputs.
π Creating the form component.
π Creating the components of each input.
π Using our Form component.
π Conclusion.π Demo.
π Source Code.
Β
π Technologies to be used.
- React JS 18.2.0
- TypeScript 4.9.3
- React Hook Form 7.43.0
- Vite JS 4.1.0
- Tailwind CSS 3.2.4 (neither the installation nor the configuration process is displayed).
π Creating the project.
We will name the project: dynamic-forms-rhf
(optional, you can name it whatever you like).
npm create vite@latest
We create the project with Vite JS and select React with TypeScript.
Then we run the following command to navigate to the directory just created.
cd dynamic-forms-rhf
Then we install the dependencies.
npm install
Then we open the project in a code editor (in my case VS code).
code .
π First steps.
Inside the src/App.tsx file we delete everything and create a component that displays a hello world
.
const App = () => {
return (
<div>Hello world</div>
)
}
export default App
π¨ Note: Each time we create a new folder, we will also create an index.ts file to group and export all the functions and components of other files that are inside the same folder, so that these functions can be imported through a single reference, this is known as barrel file.
Let's create a layout, create a folder src/components and inside create a file Layout.tsx.
export const Layout = ({ children }: { children: JSX.Element | JSX.Element[] }) => {
return (
<>
<h1 className='text-center my-10 text-5xl'>
<span>Dynamic Form</span>
<span className='font-bold bg-clip-text text-transparent text-[#EC5990]'>
{' - '}
React Hook Form
</span>
</h1>
<main className='grid sm:grid-cols-2 grid-cols-1 sm:mb-0 mb-10 gap-10 place-items-start justify-items-center px-5'>
{children}
</main>
</>
)
}
Now, inside the src/App.tsx file, we add the layout.
import { Layout } from './components'
const App = () => {
return (
<Layout>
<span>Form</span>
</Layout>
)
}
export default App
Then we are going to install the necessary packages.
- react-hook-form, to handle the forms in an easier way.
- yup, to handle form validations.
- @hookform/resolvers, to integrate yup with react-hook-form.
npm install -E react-hook-form @hookform/resolvers yup
Previously I had already done this same exercise of dynamic forms but using the Formik library, and the truth is very similar to what we are going to do, the only thing to change are the components such as the form and inputs.
π Creating the form object.
π Creating the typing for the inputs.
First, let's create the typing. We create a new folder src/types and create the index.ts file.
Now first we create the interface for the inputs, which can have even more properties, but these are enough to make this example.
The highlights are the last three properties of the InputProps interface:
- typeValue: necessary since we need to tell Yup what type of value the input accepts.
-
validations: validations that will be set to Yup based on the input; I only put basic validations, although you can integrate more if you look in the Yup documentation.
- The validation that may be more complicated for you may be oneOf, if you have not used Yup. This validation needs a reference or the name of another input to validate if both inputs contain the same content. An example of where to use this validation is in an input where you create a password and another one where you have to repeat password and both values have to match.
- options: this property is necessary only if the input is a select or a group of radio type inputs.
export interface InputProps {
type: 'text' | 'radio' | 'email' | 'password' | 'select' | 'checkbox'
name: string
value: string | number | boolean
placeholder?: string
label?: string
typeValue?: 'boolean' | 'number'
validations?: Validation[]
options?: Opt[]
}
export interface Opt {
value: string | number
desc: string
}
export interface Validation {
type: 'required' | 'isEmail' | 'minLength' | 'isTrue' | 'oneOf'
value?: string | number | boolean
message: string
ref?: string
}
Also at once we create this type for the types of forms we are going to develop.
In this case we are only going to create two forms.
export type FormSection = 'register' | 'another'
π Now we create the form object with the help of the typing.
Thanks to Typescript we can create our forms in this object.
We create a new folder src/lib and inside we create the file form.ts and add the following:
import { FormSection, InputProps } from '../types';
export const forms: { [K in FormSection]: InputProps[] } =
{
register: [
{
label: "New username",
type: "text",
name: "username",
placeholder: "New username",
value: "",
validations: [
{
type: "minLength",
value: 3,
message: "Min. 3 characters",
},
{
type: "required",
message: "Username is required"
},
],
},
{
label: "New Password",
type: "password",
name: "password",
placeholder: "New password",
value: "",
validations: [
{
type: "required",
message: "Password is required"
},
{
type: "minLength",
value: 5,
message: "Min. 5 characters",
}
],
},
{
label: 'Repeat your password',
type: "password",
name: "repeat_password",
placeholder: "Repeat password",
value: "",
validations: [
{
type: "required",
message: "Repeat password is required"
},
{
type: "minLength",
value: 5,
message: "Min. 5 characters",
},
{
type: 'oneOf',
message: 'Passwords must match',
ref: 'password'
}
],
},
],
another: [
{
label: "E-mail address",
type: "email",
name: "email",
placeholder: "correo@correo.com",
value: "",
validations: [
{
type: "required",
message: "Email is required"
},
{
type: "isEmail",
message: "Email no valid"
}
],
},
{
type: "select",
name: "rol",
label: "Select an option: ",
value: "",
options: [
{
value: "admin",
desc: "Admin",
},
{
value: "user",
desc: "User"
},
{
value: "super-admin",
desc: "Super Admin"
}
],
validations: [
{
type: "required",
message: "Rol is required"
}
]
},
{
type: "radio",
name: "gender",
label: "Gender: ",
value: "",
options: [
{
value: 'man',
desc: "Man"
},
{
value: "woman",
desc: "Woman"
},
{
value: "other",
desc: "Other"
},
],
validations: [
{
type: "required",
message: "Gender is required"
}
]
},
{
type: "checkbox",
name: "terms",
typeValue: "boolean",
label: "Terms and Conditions",
value: false,
validations: [
{
type: "isTrue",
message: "Accept the terms!"
}
]
},
]
}
π Creating the validation schema for our form.
Let's create a new file in src/lib and name it getInputs.ts.
We create a new function to generate the validations to each input.
This function receives the fields, and each field is of type InputProps. We are also going to create 2 types only so that Typescript does not bother us later.
Note that we created the types YupBoolean and YupString. If you want you can add other types either to handle some other data type like numeric or array. For example:
type YupNumber = Yup.NumberSchema<boolean | undefined, AnyObject, number | undefined>
I don't add it, because in my interfaces I don't handle any validation of type number or array.
import * as Yup from "yup";
import { AnyObject } from "yup/lib/types";
import { FormSection, InputProps } from '../types';
import { forms } from '../lib';
type YupBoolean = Yup.BooleanSchema<boolean | undefined, AnyObject, boolean | undefined>
type YupString = Yup.StringSchema<string | undefined, AnyObject, string | undefined>
const generateValidations = (field: InputProps) => {}
First we create a variable that will be initialized with the datatype that will handle our input. The datatype we obtain it from the typeValue property, in case it is undefined by default the datatype will be string, and then we execute the function
let schema = Yup[field.typeValue || 'string']()
Then we are going to go through the validations of the field, since it is an array.
Within the loop, we will use a switch case, evaluating what type of rule the field has.
const generateValidations = (field: InputProps) => {
let schema = Yup[field.typeValue || 'string']()
for (const rule of field.validations) {
switch (rule.type) { }
}
}
In each case of the switch we will overwrite the schema variable. In the following way:
If it has an 'isTrue' validation it means that the input handles Boolean values, so we want our schema to behave as a YupBoolean, otherwise Typescript would be complaining. Then we execute the function that has to do with each case.
For example, in the case of 'isTrue', we execute the function with the exact same name, and inside we pass the message
case 'isTrue' : schema = (schema as YupBoolean).isTrue(rule.message); break;
In the case that the validation is oneOf, we need to send it, as first parameter an array and as second parameter a message.
In the case of the array, it must be the value you want to match, but in this case we want to match the value of another field, so we use Yup.ref which needs a string that refers to the name attribute of an input.
So that when the validation is done, it checks if both fields contain the same value.
case 'oneOf' : schema = (schema as YupString)
.oneOf(
[ Yup.ref(rule.ref as string) ],
rule.message
);
break;
This is how our first function would look like. At the end we return the variable schema.
Note that at the beginning of the function, we place a condition where if the field has no validations then return null and avoid executing the cycle.
import * as Yup from "yup";
import { AnyObject } from "yup/lib/types";
import { FormSection, InputProps } from '../types';
import { forms } from '../lib';
type YupBoolean = Yup.BooleanSchema<boolean | undefined, AnyObject, boolean | undefined>
type YupString = Yup.StringSchema<string | undefined, AnyObject, string | undefined>
const generateValidations = (field: InputProps) => {
if (!field.validations) return null
let schema = Yup[field.typeValue || 'string']()
for (const rule of field.validations) {
switch (rule.type) {
case 'isTrue' : schema = (schema as YupBoolean).isTrue(rule.message); break;
case 'isEmail' : schema = (schema as YupString).email(rule.message); break;
case 'minLength': schema = (schema as YupString).min(rule?.value as number, rule.message); break;
case 'oneOf' : schema = (schema as YupString).oneOf([Yup.ref((rule as any).ref)], rule.message); break;
default : schema = schema.required(rule.message); break;
}
}
return schema
}
π Function to generate the inputs.
First we are going to create a function and we name it getInputs, which is of generic type and receives as parameter the section (that is to say that form you want to obtain its fields, in this case it can be the form of signUp or the other one).
We are going to create two variables that we will initialize them as empty objects and that at the end will have to contain new properties.
export const getInputs = <T>(section: FormSection) => {
let initialValues: { [key: string]: any } = {};
let validationsFields: { [key: string]: any } = {};
};
Inside the function we will make a for of loop. In which we are going to go through the fields of a specific form.
Inside the cycle, we are going to compute the values in the initialValues variable, and to compute the values we use the name property of the field.
-
We verify if there are validations for the field.
- If there are no validations, then continue with the next field.
- If there are validations, we execute the function that we created before generateValidations sending the field as argument.
Then to the validationsFields variable, we also compute the values using the name property of the field, and we assign the validation schema that has been generated.
for (const field of forms[section]) {
initialValues[field.name] = field.value;
if (!field.validations) continue;
const schema = generateValidations(field)
validationsFields[field.name] = schema;
}
Once the cycle is finished, we must return 3 properties.
- The validation schema inside a Yup.object, spreading the validationsFields properties. ```tsx
validationSchema: Yup.object({ ...validationsFields }),
- The initial values, and we will make them behave as generic so that we can use them afterwards
```tsx
initialValues: initialValues as T,
- The fields that we want to show in our form. ```tsx
inputs: forms[section]
This is what our function will look like at the end
```tsx
export const getInputs = <T>(section: FormSection) => {
let initialValues: { [key: string]: any } = {};
let validationsFields: { [key: string]: any } = {};
for (const field of forms[section]) {
initialValues[field.name] = field.value;
if (!field.validations) continue;
const schema = generateValidations(field)
validationsFields[field.name] = schema;
}
return {
validationSchema: Yup.object({ ...validationsFields }),
initialValues: initialValues as T,
inputs: forms[section],
};
};
π Creating the form component.
First we are going to prepare the interface for the props that our Form component is going to receive.
- onSubmit, function that executes the form.
- labelButtonSubmit, text that will show the button.
- titleForm, text that will show the form.
The last 3 properties are what returns the function that we did to generate the inputs and their validations.
interface Props {
onSubmit: (data: unknown) => void
labelButtonSubmit?: string
titleForm?: string
initialValues: unknown
validationSchema: SchemaForm
inputs: InputProps[]
}
The validationSchema property is of type SchemaForm.
// src/types/index.ts
export type SchemaForm = OptionalObjectSchema<{
[x: string]: any;
}, AnyObject, TypeOfShape<{
[x: string]: any;
}>>
Now we create the component, and inside we destructure the props that the component receives.
Then we use the hook of useForm, which we are going to establish an object as argument, we access to the property:
- resolver, to set the validation scheme, for this we use the function yupResolver and we pass as argument the validationSchema that comes by props.
- defaultValues, to establish the default values and we will assign the props of initialValues.
Note that we do not destructure anything of the useForm hook.
import { yupResolver } from '@hookform/resolvers/yup'
import { useForm } from 'react-hook-form'
export const Form = ({ ...props }: Props) => {
const {
initialValues,
inputs,
onSubmit,
validationSchema,
titleForm,
labelButtonSubmit = 'Submit'
} = props
const formMethods = useForm({
resolver: yupResolver(validationSchema),
defaultValues: { ...(initialValues as any) }
})
return (
<></>
)
}
Next, we are going to use a component that offers us react-hook-form, which is the FormProvider and we are going to spread the formMethods of the useForm hook.
The FormProvider will help us to communicate the state of the form with the components (inputs) that are nested inside the FormProvider. With the purpose of separating the components and not having everything in the same file.
Inside the FormProvider we will place a form and in the onSubmit method of the form label, we are going to execute a property of the formMethods, which is the handleSubmit, and as argument we pass the onSubmit that receives the component Form by props.
This handleSubmit will only be executed if there are no errors in each input, and when it is executed it will return the values of each input.
import { FormProvider, useForm } from 'react-hook-form'
// interface
export const Form = ({ ...props }: Props) => {
// props
const formMethods = useForm({
resolver: yupResolver(validationSchema),
defaultValues: { ...(initialValues as any) }
})
return (
<FormProvider {...formMethods}>
<form
onSubmit={formMethods.handleSubmit(onSubmit)}
className='bg-secondary rounded-md p-10 pt-5 shadow-2xl shadow-primary/30 flex flex-col gap-2 border border-primary w-full min-h-[390px]'
>
<section className='flex-1 flex flex-col gap-3'>
{/* inputs here */}
</section>
</form>
</FormProvider>
)
}
Now we are going to create a function, to return the different types of inputs.
We use the prop inputs that we destructured of the props that the Form component receives.
Based on the type of input we are going to render one or another input.
Note that we are using components that we have not yet created. Also note, that from the properties of each input, we are going to exclude the validations, typeValue and value, because they are values that our input does not need directly.
One thing to improve about this function, is that you can create a separate component, and create a dictionary with the components and the type of input.
In this case I do not do it, so as not to extend more.
const createInputs = () =>
inputs.map(({ validations, typeValue, value, ...inputProps }) => {
switch (inputProps.type) {
case 'select':
return <CustomSelect {...inputProps} key={inputProps.name} />
case 'checkbox':
return <CustomCheckbox {...inputProps} key={inputProps.name} />
case 'radio':
return <CustomRadio {...inputProps} key={inputProps.name} />
default:
return <CustomInput {...inputProps} key={inputProps.name} />
}
})
Finally, we execute the createInputs function inside the section tag. And immediately we are going to create the custom inputs.
// imports
// interface
export const Form = ({ ...props }: Props) => {
// props
const formMethods = useForm({
resolver: yupResolver(validationSchema),
defaultValues: { ...(initialValues as any) }
})
const createInputs = () =>
inputs.map(({ validations, typeValue, value, ...inputProps }) => {
switch (inputProps.type) {
case 'select':
return <CustomSelect {...inputProps} key={inputProps.name} />
case 'checkbox':
return <CustomCheckbox {...inputProps} key={inputProps.name} />
case 'radio':
return <CustomRadio {...inputProps} key={inputProps.name} />
default:
return <CustomInput {...inputProps} key={inputProps.name} />
}
})
return (
<FormProvider {...formMethods}>
<form
onSubmit={formMethods.handleSubmit(onSubmit)}
className='bg-secondary rounded-md p-10 pt-5 shadow-2xl shadow-primary/30 flex flex-col gap-2 border border-primary w-full min-h-[390px]'
>
<section className='flex-1 flex flex-col gap-3'>
{ createInputs() }
</section>
</form>
</FormProvider>
)
}
π Creating the components of each input.
First, we are going to create an error message, which is going to be displayed every time the input validation fails.
Inside src/components we create ErrorMessage.tsx.
interface Props { error?: string }
export const ErrorMessage = ({ error }: Props) => {
if (!error) return null
return (
<div className='w-full grid place-content-end'>
<p className='text-red-400 text-sm'>{error}</p>
</div>
)
}
Now, we are going to create a new folder src/components/inputs and inside we will create 4 files.
These four components that we are going to create receive props that are of type CustomInputProps. You can place it in the src/types/index.ts file.
export type CustomInputProps = Omit<InputProps, 'validations' | 'typeValue' | 'value'>
And also as each input that we will create will be inside a FormProvider, we can use another custom hook of react-hook-form, which is useFormContext, this hook will help us to connect the state of the form with the input.
- CustomGenericInput.tsx
From useFormContext, we obtain the register property, and the errors property inside the formState.
const {
register,
formState: { errors }
} = useFormContext()
We create the error, computing the error object with the prop name that the component receives and we obtain the message.
const error = errors[name]?.message as string | undefined
At the moment of constructing the input, we need to spread the properties of the register function, which we have to pass the prop name so that react-hook-form identifies what errors and validations this input should have.
Then, we spread the other properties in case it has more (such as the placeholder).
<input
className='py-1 px-2 rounded w-full text-black'
{...register(name)}
{...props}
id={id}
/>
This is how this component will look like in the end.
import { useFormContext } from 'react-hook-form'
import { ErrorMessage } from '../../components'
import { CustomInputProps } from '../../types'
export const CustomInput = ({ name, label, ...props }: CustomInputProps) => {
const {
register,
formState: { errors }
} = useFormContext()
const error = errors[name]?.message as string | undefined
const id = `${name}-${props.type}-${label}`
return (
<div className='w-full flex gap-1 flex-col'>
{label && (
<label className='text-white text-sm' htmlFor={id}>
{label}
</label>
)}
<input
className='py-1 px-2 rounded w-full text-black'
{...register(name)}
{...props}
id={id}
/>
<ErrorMessage error={error} />
</div>
)
}
- CustomCheckbox.tsx
import { useFormContext } from 'react-hook-form'
import { ErrorMessage } from '../../components'
import { CustomInputProps } from '../../types'
export const CustomCheckbox = ({ name, label, ...props }: CustomInputProps) => {
const {
register,
formState: { errors }
} = useFormContext()
const error = errors[name]?.message as string | undefined
return (
<div>
<label className='flex gap-2 items-center cursor-pointer w-fit'>
<input {...props} {...register(name)} />
{label}
</label>
<ErrorMessage error={error} />
</div>
)
}
- CustomSelect.tsx
This input is almost the same as all the others, only here we have the prop options where the values of the select that can be selected will come.
import { useFormContext } from 'react-hook-form'
import { ErrorMessage } from '../../components'
import { CustomInputProps } from '../../types'
export const CustomSelect = ({ name, label, options, ...props }: CustomInputProps) => {
const {
register,
formState: { errors }
} = useFormContext()
const error = errors[name]?.message as string | undefined
const id = `${name}-${props.type}-${label}`
return (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-4'>
<label htmlFor={id}>{label}</label>
<select {...register(name)} {...props} id={id} className='p-2 rounded flex-1 text-black'>
<option value=''>--- Select option ---</option>
{options &&
options.map(({ desc, value }) => (
<option key={value} value={value}>
{desc}
</option>
))}
</select>
</div>
<ErrorMessage error={error} />
</div>
)
}
- CustomRadioGroup
Very similar to CustomSelect.tsx. Only here we render an input of type radio.
import { useFormContext } from 'react-hook-form'
import { ErrorMessage } from '../../components'
import { CustomInputProps } from '../../types'
export const CustomRadio = ({ name, label, options, ...props }: CustomInputProps) => {
const {
register,
formState: { errors }
} = useFormContext()
const error = errors[name]?.message as string | undefined
return (
<div className='flex flex-col'>
<div className='flex items-center gap-4'>
<label>{label}</label>
<section className='flex justify-between flex-1'>
{options &&
options.map(({ desc, value }) => (
<label
key={value}
className='flex items-center gap-1 cursor-pointer hover:underline rounded p-1'
>
<input {...register(name)} {...props} value={value} type='radio' />
{desc}
</label>
))}
</section>
</div>
<ErrorMessage error={error} />
</div>
)
}
π Using our Form component.
Now we go to the src/App.tsx file.
To use the Form component.
We have to execute the getInputs function and get the validations, initial values and inputs. We will do it outside the component. We also create an interface so that the initial values behave like that interface.
interface SignUpFormType {
username: string
password: string
repeat_password: string
}
const signUpForm = getInputs<SignUpFormType>('register')
Then we import the Form component, we spread the properties returned by getInput. And we also pass it the other props.
import { Layout, Form } from './components'
import { getInputs } from './lib'
interface SignUpFormType {
username: string
password: string
repeat_password: string
}
const signUpForm = getInputs<SignUpFormType>('register')
const App = () => {
const onSubmitSignUp = (data: unknown) => console.log({ singUp: data })
return (
<Layout>
<Form
{...signUpForm}
onSubmit={onSubmitSignUp}
titleForm='Sign Up!'
labelButtonSubmit='Create account'
/>
</Layout>
)
}
export default App
In case you want to overwrite the initial values, you just create a new constant by spreading the initial values and then overwriting what you need. Then pass a new value to the initialValues prop.
const App = () => {
const onSubmitSignUp = (data: unknown) => console.log({ singUp: data })
const initialValuesSignUp: SignUpFormType = {
...signUpForm.initialValues,
username: '@franklin361'
}
return (
<Layout>
<Form
{...signUpForm}
initialValues={initialValuesSignUp}
onSubmit={onSubmitSignUp}
titleForm='Sign Up!'
labelButtonSubmit='Create account'
/>
</Layout>
)
}
export default App
And you can also include several forms dynamically.
import { Layout, Form } from './components'
import { getInputs } from './lib'
interface SignUpFormType {
username: string
password: string
repeat_password: string
}
interface AnotherFormType {}
const signUpForm = getInputs<SignUpFormType>('register')
const anotherForm = getInputs<AnotherFormType>('another')
const App = () => {
const onSubmitSignUp = (data: unknown) => console.log({ singUp: data })
const onSubmitAnotherForm = (data: unknown) => console.log({ another: data })
const initialValuesSignUp: SignUpFormType = {
...signUpForm.initialValues,
username: '@franklin361'
}
return (
<Layout>
<Form
{...signUpForm}
initialValues={initialValuesSignUp}
titleForm='Sign Up!'
onSubmit={onSubmitSignUp}
labelButtonSubmit='Create account'
/>
<Form
{...anotherForm}
titleForm='Another form!'
onSubmit={onSubmitAnotherForm}
labelButtonSubmit='Send info'
/>
</Layout>
)
}
export default App
π Conclusion.
React Hook Form is one of my favorite libraries, because it has certain advantages over other popular libraries such as Formik; for example the bundle size is smaller, it has fewer dependencies, produces fewer re-renders, etc. π.
But still both are very used libraries.
I hope you liked this post and I also hope I helped you to understand how to make dynamic forms using React Hook Form π.
If you know any other different or better way to make this application feel free to comment it.
I invite you to review my portfolio in case you are interested in contacting me for a project.! Franklin Martinez Lucas
π΅ Don't forget to follow me also on twitter: @Frankomtz361
π Demo.
https://dynamic-form-rhf.netlify.app/
π Source code.
Posted on March 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 24, 2023
September 29, 2023