How to Build a Dynamic, Controlled Form with React Hooks (2023)

Jamaicancoder
8 min readJan 28, 2023

React Hooks help simplify the concept of building dynamic forms. A dynamic form is one where the user decides how many inputs there will be. React’s new hooks, such as useState, make building UI’s easier than ever, but building dynamic forms can still be a bit tricky to understand. This tutorial explains how to build a dynamic form using React Hooks. The tutorial starts by planning the components and state that will be needed, then moves on to rendering the form, adding dynamic inputs, and finally controlling the inputs using events. The tutorial uses an array of cat objects in state to represent the dynamic inputs, and a button that adds new inputs to the array, triggering a re-render of the form.

Getting started with a plan

Planning is crucial with React. It will save you so much time if you think about what you want before you start coding. We know what it will look like, but how will it be made? I like to go through a little React checklist:

  1. What components will I need?
  2. Which of them will have state?
  3. Are there user triggered events?

In this case, I feel like it would be nice to have a main Form component, and then aCatInputs component that renders the dynamic inputs. As for state, our Form component will need it, since we’ll be controling each input. But our CatInputs components will not require state, since we can just pass everything in as props.

Finally, there are 2 events: we need to handle changes to each input, and we need to handle the button that adds new inputs. For this tutorial, we won’t worry about the submit action since it isn’t relevant. Submitting a dynamic form is the same as submitting a regular form.

Order of Attack

In general, I like to render whatever inputs I need first, and then start working on the interactivity. In this case, we’ll render the static base Form, then figure out how to render the new inputs, and then finally we’ll deal with controlling them. I will build everything in the Form component first, and then once it all works, I’ll refactor the proper section into a CatInputs component.

Getting started: rendering it out

Let’s build out the non interactive part of the form first (gist link):

// /src/Form.js
import React from 'react';
const Form = () => {
return (
<form>
<label htmlFor="owner">Owner</label>
<input type="text" name="owner" id="owner" />
<label htmlFor="description">Description</label>
<input type="text" name="description" id="description" />
<input type="button" value="Add New Cat" />
<input type="submit" value="Submit" />
</form>
);
}; export default Form;

This is what we made (here’s the beautiful styling):

Using arrays for dynamic inputs

Before we code, we should talk about how we are going to do this. Basically, we’re going to have an array of cat objects in our state. Each object will have a name and age value. Our Form will iterate over this list and create two new inputs for the name and age. When we click the “add new cat” button, we’ll add a new object to our array. Since this will change our state, it will trigger a re-render. Then, our form will iterate over this new list of cats, and add another pair of inputs.

To start, let’s just worry about putting the first blank cat object into our state. Now, here come the new hooks! Remember, we’re using array destructuring for assignment, and the first item is our state itself, and the second is the function that we use to update it. We put our initial state as the parameter of useState. Also, you can call these whatever you want, I just put “state” on at the end because I like it (gist link):

import React, { useState } from 'react'; 
const Form = () => {
const [catState, setCatState] = useState([
{ name: '', age: '' },
]); return (
<form>
<label htmlFor="owner">Owner</label>
<input type="text" name="owner" id="owner" />
<label htmlFor="description">Description</label>
<input type="text" name="description" id="description" />
<input type="button" value="Add New Cat" />
{
catState.map((val, idx) => {
const catId = `name-${idx}`;
const ageId = `age-${idx}`;
return (
<div key={`cat-${idx}`}>
<label htmlFor={catId}>{`Cat #${idx + 1}`}</label>
<input
type="text"
name={catId}
data-idx={idx}
id={catId}
className="name"
/>
<label htmlFor={ageId}>Age</label>
<input
type="text"
name={ageId}
data-idx={idx}
id={ageId}
className="age"
/>
</div>
);
})
}
<input type="submit" value="Submit" />
</form>
);
};export default Form;

That’s a new big chunk, but it’s not complex if you break it down. I’m mapping over my array of cats from my catState, and using the map’s index value to assign each pair of inputs unique ids, names, keys, and labels. You should always include labels to ensure your site is accessible and screenreader friendly. And that data-idx attribute will be crucial to controlling our inputs later. It’s going to match the inputs to the index of the corresponding cat object in the array.

Adding inputs

So we’re using an array, but it’s not dynamic yet. Since our form is creating two new inputs, we know that the iteration aspect is working. But for it to truly be dynamic, we have to be able to let the user add the inputs. We just need to give our component a method that adds a new blank cat to our array. We just need to add this to our button type inputs. Type button inputs (not button elements) do not submit the form, so we don’t need to worry about stopping the submission (gist link):

import React, { useState } from 'react'; 
const Form = () => {
const blankCat = { name: '', age: '' };
const [catState, setCatState] = useState([
{...blankCat}
]);

const addCat = () => {
setCatState([...catState, {...blankCat}]);
}; return (
<form>
<label htmlFor="owner">Owner</label>
<input type="text" name="owner" id="owner" />
<label htmlFor="description">Description</label>
<input type="text" name="description" id="description" />
<input
type="button"
value="Add New Cat"
onClick={addCat}
/>
{
catState.map((val, idx) => {// rest unchanged

All addCat does is set the state with a spread of the previous state’s catsarray, and a new blankCat object tagged on the end. Note that I refactored the starting cat object into a variable. I’m using it as a base to clone objects, and if you don’t know why copying the object like that is important, here’s an explanation of object references. Also, note that we don’t need anything like prevState for something this simple. Cool right? Hooks are the FUTURE . Now, whenever we click our addCat button it will add one cat to our state, which will trigger a re-render and show our new, user-added input!

Controlling the static inputs

Now that we have our inputs done, let’s control them. First the easy part, the non-dynamic inputs. We’ll do this by adding a separate owner state (gist link):

const Form = () => {
const [ownerState, setOwnerState] = useState({
owner: '',
description: '',
});
const handleOwnerChange = (e) => setOwnerState({
...ownerState,
[e.target.name]: [e.target.value],
}); const blankCat = { name: '', age: '' };
const [catState, setCatState] = useState([
{ ...blankCat },
]);

const addCat = () => {
setCatState([...catState, { ...blankCat }]);
}; return (
<form>
<label htmlFor="owner">Owner</label>
<input
type="text"
name="owner"
id="owner"
value={ownerState.owner}
onChange={handleOwnerChange}
/>
<label htmlFor="description">Description</label>
<input
type="text"
name="description"
id="description"
value={ownerState.owner}
onChange={handleOwnerChange}
/>// rest unchanged

By using another state, we also make things much more readable. This is one of the benefits of the new state hooks, they help make things more bite size. To get the value of the input that the user typed into, we’re using good old e.target.value. But we’re using Computed Property Names (the [] around a property) so that we can dynamically match properties by using the name attribute. To break it down, our owner input has a name of 'owner', which means our state translates to owner: "whatever-was-typed". We are also spreading out the current owner state. This is crucial because the new useState hook doesn’t merge state, it fully replaces it. If we didn’t spread, we would lose all other properties.

Controlling the dynamic inputs

Now for the fancy part; handling our dynamic inputs:

const handleCatChange = (e) => {
const updatedCats = [...catState];
updatedCats[e.target.dataset.idx][e.target.className] = e.target.value;
setCatState(updatedCats);
};

The first thing we do is clone our catState so we keep renders pure. Next, we use the idx data attribute to locate the index of the particular set of cat inputs. Then, to find out if it’s the name or age that’s been changed, we use the className attribute. Notice that we have to use the className and not just name. This is because there’ll be more than one cat and since the name attribute has to be unique, we can’t use it. By using the className we can just set it to match our cat property names and call it a day.

All that gives us the exact cat and property, so then we can use e.target.value to actually set the value, just like before. Finally, we call setCatState with our updated array of cats.

Adding the value and onChange attributes to our cat inputs has a tiny gotcha. While the onChange function is the same, be sure that each value is getting the right property, either .name or .age:

{
catState.map((val, idx) => {
const catId = `name-${idx}`;
const ageId = `age-${idx}`;
return (
<div key={`cat-${idx}`}>
<label htmlFor={catId}>{`Cat #${idx + 1}`}</label>
<input
type="text"
name={catId}
data-idx={idx}
id={catId}
className="name"
value={catState[idx].name}
onChange={handleCatChange}
/>
<label htmlFor={ageId}>Age</label>
<input
type="text"
name={ageId}
data-idx={idx}
id={ageId}
className="age"
value={catState[idx].age}
onChange={handleCatChange}
/>
</div>
);
})
}

Breakout the cat inputs

You just made a dynamic, controlled form using React Hooks! Woo! Here’s the whole thing in one piece on gist. For one last step, why not try breaking out the CatInputs as a separate piece? As we discussed earlier, let’s make it a purely functional component without state that just renders a pair of inputs:

import React from 'react';
import PropTypes from 'prop-types';const CatInputs = ({ idx, catState, handleCatChange }) => {
const catId = `name-${idx}`;
const ageId = `age-${idx}`;
return (
<div key={`cat-${idx}`}>
<label htmlFor={catId}>{`Cat #${idx + 1}`}</label>
<input
type="text"
name={catId}
data-idx={idx}
id={catId}
className="name"
value={catState[idx].name}
onChange={handleCatChange}
/>
<label htmlFor={ageId}>Age</label>
<input
type="text"
name={ageId}
data-idx={idx}
id={ageId}
className="age"
value={catState[idx].age}
onChange={handleCatChange}
/>
</div>
);
};CatInputs.propTypes = {
idx: PropTypes.number,
catState: PropTypes.array,
handleCatChange: PropTypes.func,
};export default CatInputs;

To see everything put together, check out the last gist (and don’t forget to use prop-types). Follow this basic pattern as a jumping off point for your next project, and try to find some parts to streamline once you understand the process.

--

--