От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!
От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!
Ihor Belehai
/
Front End Developer
9 min read
We return to our series of articles where we learn about building forms with React, Typescript, React Hook Form, Material UI, and Yup. In the previous article, we learned how to add MaterialUI and integrate it with React Hook Form. Also, we improved our Yup validation. This is the final article of the series, and we’ll complete it with a guide to building dynamic array fields with useFieldArray.
Quick navigation between related articles:
And again, if you want to see the final source code of the series, follow the link.
As always, you can also play with the demo.
Content:
If you want to brush up on form creation, validation, and utilities, we recommend you read the related articles from our series. This article will be helpful if you wish to dive deeper into building dynamic array fields with useFieldArray.
Do you remember the wireframe we received from our client and discussed in the first article? Here it is:
Now, the client has come up with new ideas and wants to supplement them with essential functionality. Here’s the new wireframe I’ve received:
As a result of the changes to be made, we have a list of new requirements:
Well, this looks pretty challenging. Let’s implement the new requirements.
export interface Appointment { id?: string; title: string; date: string; plan: AppointmentPlan; description?: string; contact: ContactInfo; pets: Pet[]; }
2. Now, let’s recall our AppointmentForm component:
export const AppointmentForm = () => { const form: UseFormReturn<Appointment, UseFormProps> = useForm<Appointment>({ defaultValues, resolver: yupResolver(validationSchema), }); const submitForm = (form: Appointment) => { // todo: do whatever after submitting }; return ( <FormProvider {...form}> <form onSubmit={form.handleSubmit(submitForm)}> <h1 className="font-semibold my-4 text-2xl text-center"> Create a new appointment </h1> <AppointmentBaseForm /> <AppointmentPetForm /> <AppointmentContactForm /> <Button type="submit" variant="outlined" fullWidth className="mt-4"> Make an appointment! </Button> </form> {/*<DevTool control={form.control} /> /!* set up the dev tool *!/*/} </FormProvider> ); };
3. Let’s modify defaultValues according to our new model:
const defaultValues: Appointment = { title: '', date: new Date().toString(), plan: AppointmentPlan.Basic, contact: { firstName: '', lastName: '', email: '', phoneNumber: '', callMeBack: false, }, pets: [ { name: '', breed: '', description: '', }, ], };
4. Modify validation.ts:
pets: yup.array( yup.object({ name: yup.string().required().min(2).max(120).label("Name"), breed: yup.string().required().min(2).max(120).label("Breed"), description: yup.string().min(2).max(120).label("Description"), }) ),
For starters, go to components/AppointmentPetForm.tsx and look at our existing component:
export const AppointmentPetForm = () => { return ( <section> <h2 className="font-semibold my-4 text-lg"> Pet information </h2> <div className="grid grid-cols-2 gap-6"> <div className="col-span-1"> <TextInput name="pet.name" label="Name*" placeholder="Enter" /> </div> <div className="col-span-1"> <TextInput name="pet.breed" label="Breed*" placeholder="Enter" /> </div> <div className="col-span-2"> <TextInput name="pet.description" label="Description" placeholder="Enter" /> </div> </div> </section> ); };
We are going to change this component a little. But first, let’s create a parent component that will iterate through the array of pets and display AppointmentPetForm.
In order to create an array field, you need to pass the control of your form and specify a property name that needs to be an array. In our case, the name is pets:
import { useFieldArray, useFormContext, UseFieldArrayReturn } from 'react-hook-form'; export const AppointmentPets = () => { const form = useFormContext<Appointment>(); const petsField: UseFieldArrayReturn<Appointment> = useFieldArray<Appointment>({ control: form.control, name: 'pets', }); return ( <section> <h2 className="font-semibold my-4 text-lg">Pets</h2> </section> ) };
According to the docs, petsField: UseFieldArrayReturn:
has the following property:
// petsField.fields initial value: [{ id: 'some-auto-generated-id', // explicitly set via defaultValues name: '', breed: '', description: '' }]
And the following methods:
3. Now, let’s iterate through our array of pets and display our AppointmentPetForm for each pet:
import { AppointmentPetForm } from './AppointmentPetForm'; // ... return ( <section> <h2 className="font-semibold my-4 text-lg">Pets</h2> {/* at the beginning it's going to inherit value from devafaultValues */} {petsField.fields.map((field, index) => ( {/* unique id is automatically generated by useFieldArray */ } <AppointmentPetForm key={field.id} /> ))} </section> )
4. Add the “Add another pet” button from MaterialUI. Don’t forget to set it type=”button” because it might consider it as type=”submit” by default (this is not the case with MaterialUI but is with native HTML elements):
import { Button } from '@mui/material'; // ... return ( <section> <h2 className="font-semibold my-4 text-lg">Pets</h2> {petsField.fields.map((field, index) => ( // unique id is automatically generated by useFieldArray <AppointmentPetForm key={field.id} /> ))} <Button type="button" variant="outlined" fullWidth className="mt-6" > Add another pet </Button> </section> )
5. Now, go to components/AppointmentPetForm.tsx and modify the component slightly to support the field array:
Describe the props interface:
interface AppointmentPetFormProps { index: number }
We’ll receive an index as a prop. That’s why our following code snippet looks like this:
export const AppointmentPetForm = ({ index }: AppointmentPetFormProps) => { return ( <section> {/* Display the order number of the pet */} <p className="mb-2 text-gray-400 font-semibold">Pet {index + 1}</p> <div className="grid grid-cols-2 gap-6 pb-6"> <div className="col-span-1"> {/* Bind input to the corresponding array index */} <TextInput name={`pets[${index}].name`} label="Name*" placeholder="Enter" /> </div> <div className="col-span-1"> <TextInput name={`pets[${index}].breed`} label="Breed*" placeholder="Enter" /> </div> <div className="col-span-2"> <TextInput name={`pets[${index}].description`} label="Description" placeholder="Enter" /> </div> </div> </section> ); };
Let’s run the app with React Hook Form DevTools, enter something into the pet form, and look at the DevTools:
It looks excellent at this stage. Now that we’ve successfully set up an array field let’s enable our client to add another pet.
const addNewPet = () => { petsField.append({ name: "", breed: "", description: "", }); };
2. Bind this function as the onClick handler of our button:
<Button type="button" variant="outlined" fullWidth className="mt-6" onClick={addNewPet} > Add another pet </Button>
3. Run the app, press “Add another pet,” and make sure that the new field is added.
If you need some help, here’s the code of the AppointmentPets component at this particular point:
export const AppointmentPets = () => { const form = useFormContext<Appointment>(); const petsField: UseFieldArrayReturn<Appointment> = useFieldArray<Appointment>({ control: form.control, name: 'pets', }); const addNewPet = () => { petsField.append({ name: '', breed: '', description: '', }); }; return ( <section> <h2 className="font-semibold my-4 text-lg">Pets</h2> {petsField.fields.map((field, index) => ( <AppointmentPetForm key={field.id} index={index} /> ))} <Button type="button" variant="outlined" fullWidth className="mt-6" onClick={addNewPet} > Add another pet </Button> </section> ); };
Now, with the ability to add multiple pets, let’s restrict this only to users with the Premium plan selected.
At this point, our task is to know whether a user has selected the exact Premium plan in the dropdown. We need something to let us get the values of any inputs at that moment. React Hook Form provides two ways to do this:
const Example = () => { const { watch } = useForm() // or const { watch } = useFormContext() const prop = watch('propName') return null }
const Example = () => { const { control } = useForm() // or const { control } = useFormContext() const prop = useWatch({ control, name: "propName", // without supply name will watch the entire form, or ['firstName', 'lastName'] to watch both defaultValue: "default" // default value before the render }); return null }
I decided to go ahead with useWatch. Let’s watch for the plan property and store it in the variable. Write to the following code in your AppointmentPets component:
const selectedPlan = useWatch({ control: form.control, name: 'plan' })
And add the following condition into the addNewPet() function:
const addNewPet = () => { if (selectedPlan !== AppointmentPlan.Premium) { return; } petsField.append({ name: "", breed: "", description: "", }); };
Now, a new pet will never append if the Premium plan is not selected. Let’s also disable the button to prevent this method from calling at all:
<Button type="button" variant="outlined" fullWidth className="mt-6" onClick={addNewPet} // here disabled={selectedPlan !== AppointmentPlan.Premium} > Add another pet </Button>
In the end, let’s show the label ‘You should select the Premium plan to add more than one pet’ under the button to let users know what they have to do:
<p className="h-8 text-xs mt-2 text-center text-gray-400"> {selectedPlan !== AppointmentPlan.Premium && "You should select Premium plan to add more than 1 pet"} </p>
To clarify the code, let’s use the expression selectedPlan !== AppointmentPlan.Premium to another variable with useMemo():
const isPremiumSelected = useMemo( () => selectedPlan === AppointmentPlan.Premium, [selectedPlan] );
I suggest you run the app and see the result:
Now, let’s move on and add the ability to remove pets from the form.
Applying remove() to remove values
Going back to the AppointmentPets component, let’s create another function that will take an index as an argument and call the remove() method:
const removePet = (index: number) => { petsField.remove(index); }
Now, we have to pass removePet() as a prop to each AppointmentPetForm component. Let’s see how to make that possible: Go to /components/AppointmentPetForm.tsx and modify the code slightly:
interface AppointmentPetFormProps { index: number; // here onRemove: (index: number) => void disableRemoveButton: boolean }
2. Add a “Remove” button and bind its onClick to onRemove passing index as an argument:
import React from "react"; import { TextInput } from "./TextInput"; import { Button } from "@mui/material"; interface AppointmentPetFormProps { index: number; onRemove: (index: number) => void; disableRemoveButton: boolean } export const AppointmentPetForm = ({ index, onRemove, disableRemoveButton, }: AppointmentPetFormProps) => { return ( <section> <div className="flex items-center justify-between mb-4"> <p className="mb-2 text-gray-400 font-semibold">Pet {index + 1}</p> {/* Here */} {showRemoveButton && ( <Button color="error" variant="outlined" disabled={disableRemoveButton} onClick={() => onRemove(index)} > Remove </Button> )} </div> // ... and the rest of the code ... </div> </section> ); };
3. Go back to the AppointmentPets component and pass removePet as a prop to the AppointmentPetForm:
{petsField.fields.map((field, index) => ( <AppointmentPetForm key={field.id} index={index} onRemove={removePet} disableRemoveButton={petsField.fields.length === 1} /> ))}
4. After that, add the following condition to removePet():
c const removePet = (index: number) => { // here if (petsField.fields.length === 1) { return; } petsField.remove(index); };
5. And finally, run the app and see it in action: