Step 1: Set Up a New Project and Install Dependencies
First, let’s create a new React project using Vite.
npm create vite@latest- Enter your project name (we’ll use “dynamic-form” for this tutorial).
- Select React as your framework.
- Choose JavaScript (or TypeScript if you prefer).
- Complete the installation process.
- Once the project is set up, navigate into your project folder and install the required Node modules:
npm install
# or simply
npm iInstalling Additional Dependencies
To build our dynamic form, we’ll need a few more packages:
- react-hook-form – For flexible and performant form management.
- tailwindcss – For quick and easy styling.
- yup – For form validation.
- @hookform/resolvers – To integrate Yup validation with React Hook Form.
Run the following command to install them:
npm install react-hook-form tailwindcss @tailwindcss/vite yup @hookform/resolversTroubleshooting Tip: If you encounter dependency conflicts (like peer dependency errors showing the below screenshot), try installing with --legacy-peer-deps:
Step 2: Configure Tailwind CSS and Set Up the Project
a) Update index.css
Open your index.css file and add the Tailwind directives at the top and some common styles for our form
@import "tailwindcss";
input {
border: none;
outline: none;
padding: 6px 12px;
border: 1px solid gray;
border-radius: 5px;
overflow: hidden;
width: 100%;
background-color: #f1f1f1;
}
body {
padding-top: 50px;
} b) Add Tailwind to vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
}); c) Create the UserDetailsForm Component
- Inside the
srcfolder, create acomponentsdirectory. - Add a new file:
UserDetailsForm.jsx.
d) Run the Project
npm run devStep 3: Build the Dynamic Form with useFieldArray
a) Set Up React Hook Form
In UserDetailsForm.jsx, import the required hooks and define the form structure:
const UserDetailsForm = () => {
const {
register,
handleSubmit,
setValue,
getValues,
trigger,
reset,
control,
formState: { errors, isValid },
} = useForm({
defaultValues: { userDetails: [{ name: "", age: "", place: "" }] },
mode: "all",
});
const { fields, append } = useFieldArray({
control,
name: "userDetails",
});Key Points Explained:
useForm: Manages form state, validation, and submission.defaultValues: Initializes the form with one empty user object.mode: "onChange": Validates input in real-time.
useFieldArray: Handles dynamic fields (add/remove).control: Links the field array to the form.name: "userDetails": The array where dynamic data is stored.fields: Current array of user entries.append: Function to add new user fields.
Step 4: Render Dynamic Form Fields
Now, let’s map through the fields array to generate dynamic input fields for name, age, and place.
a) Update UserDetailsForm.jsx
<form
onSubmit={handleSubmit(submitForm)}
className="p-6 border-2 border-solid border-gray-400 rounded-md overflow-hidden max-w-7xl mx-auto flex flex-col gap-y-5"
>
{fields?.map((inputField, index) => {
return (
<div key={inputField?.id} className="grid grid-cols-12 gap-5">
<div className="col-span-4 relative">
<input
{...register(`userDetails[${index}].name`, {
required: "Name is required",
})}
placeholder="Name"
name={`userDetails[${index}].name`}
onChange={(e) =>
setValue(`userDetails[${index}].name`, e.target.value)
}
/>
</div>
<div className="col-span-4 relative">
<input
{...register(`userDetails[${index}].age`, {
required: "Age is required",
})}
placeholder="Age"
name={`userDetails[${index}].age`}
onChange={(e) =>
setValue(`userDetails[${index}].age`, e.target.value)
}
/>
</div>
<div className="col-span-4 relative">
<input
{...register(`userDetails[${index}].place`, {
required: "place is required",
})}
placeholder="place"
name={`userDetails[${index}].place`}
onChange={(e) =>
setValue(`userDetails[${index}].place`, e.target.value)
}
/>
</div>
</div>
);
})}
</form>Key Explanations
- Dynamic Field Registration
{...register(userDetails.${index}.name)}:- Registers each input under
(e.g.,userDetails[index].age).userDetails[0].name - Uses template literals to dynamically bind fields to their array index.
- Registers each input under
- Why
userDetails.${index}.name?- Since
is an array, each field must be uniquely mapped to its index (e.g.,userDetails,userDetails[0].name).userDetails[1].name
- Since
- Append
append(): Adds a new group of fields (with empty values).
Now below the fields mapping add 2 buttons to submit form and add more data
<div className="flex items-center justify-between mt-5">
<button
className="px-4 py-2 rounded-md overflow-hidden cursor-pointer border-[1px] border-solid border-gray-300 hover:opacity-75 bg-orange-500 text-white"
type="submit"
>
Submit
</button>
<button
style={{ cursor: isValid ? "pointer" : "not-allowed" }}
disabled={!isValid}
aria-disabled={!isValid}
onClick={addMore}
className="px-4 py-2 rounded-md overflow-hidden cursor-pointer border-[1px] border-solid border-gray-300 hover:opacity-75"
type="button"
>
Add More
</button>
</div>Now create addMore function to add more dynamic fields
const addMore = async () => {
const formValues = getValues();
const valid = await trigger();
console.log({ valid, formValues });
if (valid) {
append({
name: "",
age: "",
place: "",
});
reset(formValues, {
keepDirty: false,
keepValues: true,
});
}
};when we click add more hook form’s append method will be called fields “name”, ‘age” and “place”
now use reset with exising formValues to make persist existing form fields and its values
now create a form submit function
const submitForm = (e) => {
e.preventDefault();
// Here you can integrate an API if you want
};Now that our dynamic form is complete, we need to add proper validation. We want to prevent users from adding new fields until all current fields contain valid data. We’ll achieve this using the Yup validation library we installed earlier.
import * as yup from "yup";
const schema = yup.object().shape({
userDetails: yup
.array()
.of(
yup.object().shape({
name: yup.string().required("Name is required"),
age: yup.string().required("Age is required"),
place: yup.string().required("Place is required"),
})
)
.min(1, "At least one user detail is required"),
});Now connect oru validation schema with hook-form using yup-resolver
const {
register,
handleSubmit,
setValue,
getValues,
trigger,
reset,
control,
formState: { errors, isValid },
} = useForm({
defaultValues: { userDetails: [{ name: "", age: "", place: "" }] },
resolver: yupResolver(schema),
mode: "all",
});Let’s add validation error messages below each input field.
When a user enters invalid data, we’ll display helpful error messages right below the problematic field. Here’s how we’ll implement it for each input:
<p className="text-xs text-red-500">
{errors?.userDetails?.[0]?.name?.message}
</p>Final code our form component will look like:
import { useFieldArray, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
// Yup validation schema
const schema = yup.object().shape({
userDetails: yup
.array()
.of(
yup.object().shape({
name: yup.string().required("Name is required"),
age: yup.string().required("Age is required"),
place: yup.string().required("Place is required"),
})
)
.min(1, "At least one user detail is required"),
});
const UserDetailsForm = () => {
const {
register,
handleSubmit,
setValue,
getValues,
trigger,
reset,
control,
formState: { errors, isValid },
} = useForm({
defaultValues: { userDetails: [{ name: "", age: "", place: "" }] },
resolver: yupResolver(schema),
mode: "all",
});
console.log({ errors });
const { fields, append } = useFieldArray({
control,
name: "userDetails",
});
const addMore = async () => {
const formValues = getValues();
const valid = await trigger();
console.log({ valid, formValues });
if (valid) {
append({
name: "",
age: "",
place: "",
});
reset(formValues, {
keepDirty: false,
keepValues: true,
});
}
};
console.log({ isValid });
const submitForm = (e) => {
e.preventDefault();
};
return (
<form
onSubmit={handleSubmit(submitForm)}
className="p-6 border-2 border-solid border-gray-400 rounded-md overflow-hidden max-w-7xl mx-auto flex flex-col gap-y-5"
>
{fields?.map((inputField, index) => {
return (
<div key={inputField?.id} className="grid grid-cols-12 gap-5">
<div className="col-span-4 relative">
<input
{...register(`userDetails[${index}].name`, {
required: "Name is required",
})}
placeholder="Name"
name={`userDetails[${index}].name`}
onChange={(e) =>
setValue(`userDetails[${index}].name`, e.target.value)
}
/>
<p className="text-xs text-red-500">
{errors?.userDetails?.[0]?.name?.message}
</p>
</div>
<div className="col-span-4 relative">
<input
{...register(`userDetails[${index}].age`, {
required: "Age is required",
})}
placeholder="Age"
name={`userDetails[${index}].age`}
onChange={(e) =>
setValue(`userDetails[${index}].age`, e.target.value)
}
/>
<p className="text-xs text-red-500">
{errors?.userDetails?.[0]?.age?.message}
</p>
</div>
<div className="col-span-4 relative">
<input
{...register(`userDetails[${index}].place`, {
required: "place is required",
})}
placeholder="place"
name={`userDetails[${index}].place`}
onChange={(e) =>
setValue(`userDetails[${index}].place`, e.target.value)
}
/>
<p className="text-xs text-red-500">
{errors?.userDetails?.[0]?.place?.message}
</p>
</div>
</div>
);
})}
<div className="flex items-center justify-between mt-5">
<button
className="px-4 py-2 rounded-md overflow-hidden cursor-pointer border-[1px] border-solid border-gray-300 hover:opacity-75 bg-orange-500 text-white"
type="submit"
>
Submit
</button>
<button
style={{ cursor: isValid ? "pointer" : "not-allowed" }}
disabled={!isValid}
aria-disabled={!isValid}
onClick={addMore}
className="px-4 py-2 rounded-md overflow-hidden cursor-pointer border-[1px] border-solid border-gray-300 hover:opacity-75"
type="button"
>
Add More
</button>
</div>
</form>
);
};
export default UserDetailsForm;
