image_logo_meet

От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!

cookies

Hi! This website uses cookies. By continuing to browse or by clicking “I agree”, you accept this use. For more information, please see our Privacy Policy

bg

Effective forms: building dynamic array fields with useFieldArray

author

Ihor Belehai

/

Front End Developer

9 min read

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:

  1. Building Effective Forms with React Hook Form, Typescript, Material UI, and Yup
  2. Effective forms: form validation with Yup, React Hook Form, and Typescript
  3. Effective forms: using the Material UI and React Hook Form utilities

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:

  1. Prerequisites
  2. Modifying the existing code
  3. How to implement dynamic array fields with useFieldArray
  4. Using append to push values to the array
  5. Getting the current value of any field with watch and useWatch
  6. Applying remove to remove values
  7. Conclusionnerdzlab image Alt - nerdzlab image Alt

Prerequisites

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:

 

nerdzlab image Alt - nerdzlab image Alt

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:

nerdzlab image Alt - nerdzlab image Alt

As a result of the changes to be made, we have a list of new requirements:

  • Add an ability to attach multiple pets only when the selected plan is Premium.
  • Add the “Add another pet” button to attach a new pet. If the chosen plan is not Premium, disable the button and show the “You should select the Premium plan to add more than one pet” message.
  • If you added more than one pet and then switched to the Basic plan again, only the first pet should be active; the rest should be disabled.
  • Add the “Remove” button to delete a particular pet if quantity is more than one.
  • Add the “Clear” button to reset the state of the whole form.

Well, this looks pretty challenging. Let’s implement the new requirements.

Modifying the existing code

  1. First, let’s change our model. Go to models.ts and change pet property to pets and say that it takes an array of pets:
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"),
  })
),

How to implement dynamic array fields with useFieldArray

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.

  1. Create components/AppointmentPets.tsx and declare AppointmentPets component
  2. Import useFieldArray and useFormContext and use them to create the array form field.

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:

  • Fields – the value of our field array. useFieldArray automatically generates a unique identifier named id for each element.  It will be used as a key prop. In the beginning, it will inherit the value from defaultValue. In our case, it looks like this: 
// petsField.fields initial value:
[{
	id: 'some-auto-generated-id',

	// explicitly set via defaultValues
	name: '',
	breed: '',
	description: ''
}]

And the following methods:

  • append – appends input/inputs to the end of your fields and focus. The input value will be registered during this action.
  • prepend – prepends input/inputs to the start of your fields and focus. The input value will be registered during this action.
  • insert – inserts input/inputs at a particular position and focus.
  • swap – swaps input/inputs position
  • move – moves input/inputs to another position
  • update – updates input/inputs at a particular position
  • replace – replaces the total field array values
  • remove – removes input/inputs at a particular position – or removes all when no index is provided.

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: nerdzlab image Alt - nerdzlab image Alt

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.

Using append() to push values to the array

  1. Go to components/AppointmentPets.tsx and add a new function in the body of the AppointmentPets component. It will call the append method of petsField previously derived from useFieldArray, passing it a boilerplate pet object.
    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. nerdzlab image Alt - nerdzlab image Alt

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.

Getting the current value of any field with watch and useWatch

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:

  • watch – watches specified inputs and returns their values:
const Example = () => {
	const { watch } = useForm()
	// or const { watch } = useFormContext() 
	const prop = watch('propName')
	
	return null
}
  • useWatch – according to the docs, it does the same as watch, but with possible performance benefits:
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: nerdzlab image Alt - nerdzlab image Alt

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:

  1. Describe new props onRemove() and disableRemoveButton (we’ll disable the remove button if there’s only one pet):
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:

nerdzlab image Alt - nerdzlab image Alt