Forms seemed very simple so far. Indeed, when we only have a fixed set of fields, it's pretty easy to make them into code.
In real apps, however, forms are often a bit more lively and dynamic. A common example is having a set of similar inputs that are backed by an array.
For example, say we have an incorporation form. Among other fields, a company has a variable number of shareholders.
Typically when dealing with array inputs, here are the things we can do with them:
- add a new item
- delete an existing item
- change details of an item
How do we make a form that allows editing such an array?
Data model
It always helps to think about the data first.
It might seem simple at first.
['Shareholder 1', 'Shareholder 2']
but... in life there's always more, to everything.
Avoid primitive types.
While it might be tempting to make an array of strings to represent shareholders (or anything else that seems to only require one input), there are a couple of potential pitfalls:
- Requirements change all the time. A shareholder might need an address and an amount of shares. Changing the code from array of strings to array of objects might be painful.
- Using primitives, we're left with indexing the array by indices. This, however, won't play nice with features like re-ordering. Having an object means we can add a unique ID to each item easily if we need to.
A better default would be to always start with an array of objects.
{ name: '' }
It gives you a chance to give a meaningful name to the field, even if you only have one; as well as makes future additions of fields easier.
But what if I really need an array of primitives?
This storage format is convenient within the form, but it's important to note that you can send to, and receive from, the server the data in any format. Even an array of strings. You will just have to transform between an array of strings and an array of objects, and vice versa, when interacting with the server.
Operations on data
Recall that we need to do the following with the array:
- add a new item
- delete an existing item
- change details of an item
Adding an item is easy.
For deleting an item, we're going to need to identify it somehow. An index will do for our case.
To change a field, besides an identifier, we need to know which field to change. There is more than one valid way to implement this use case:
- have a generic function which accepts a field name; or
- have several specialized functions, one for each field.
The former could serve well to reduce boilerplate if there are several fields. The latter could be more flexible, as it will allow to execute different logic depending on the field.
For the purpose of this post, I only have one field and will make a specialized function.
Don't mutate the array
While it could be tempting to push
to the array or do something like this.state.shareholders[idx].name = newName
, this is not a sound approach.
First, React will not know that something has changed if you do this. This is because React is only re-rendering when either the props or the state change. Mutating the state object, or any nested object, keeps the identity of the object, and React thinks nothing has changed.
We have to call setState
with a new value to let React know it should re-render.
Second, mutations are prone to unexpected bugs. Using non-mutating methods for changing arrays is not that hard.
To add a new item, you can use the .concat
method on array, and set the resulting array with setState
:
this.setState({
shareholders: this.state.shareholders.concat([{ name: '' }]),
});
To remove an item, using .filter
is the easiest non-mutating alternative:
// assuming `idx` is defined and is an index of an item to remove
this.setState({
shareholders: this.state.shareholders.filter((s, _idx) => _idx !== idx),
});
And finally, to change an existing item, we can make use of .map
and Object.assign
/object spread notation:
this.setState({
shareholders: this.state.shareholders.map((s, _idx) => {
if (_idx !== idx) return s;
// this is gonna create a new object, that has the fields from
// `s`, and `name` set to `newName`
return { ...s, name: newName };
}),
});
Piecing it all together
Rendering the input for each shareholder is trivial: we just loop over this.state.shareholders
.
class IncorporationForm extends React.Component {
constructor() {
super();
this.state = {
name: '',
shareholders: [{ name: '' }],
};
}
// ...
handleShareholderNameChange = (idx) => (evt) => {
const newShareholders = this.state.shareholders.map((shareholder, sidx) => {
if (idx !== sidx) return shareholder;
return { ...shareholder, name: evt.target.value };
});
this.setState({ shareholders: newShareholders });
}
handleSubmit = (evt) => {
const { name, shareholders } = this.state;
alert(`Incorporated: ${name} with ${shareholders.length} shareholders`);
}
handleAddShareholder = () => {
this.setState({
shareholders: this.state.shareholders.concat([{ name: '' }])
});
}
handleRemoveShareholder = (idx) => () => {
this.setState({
shareholders: this.state.shareholders.filter((s, sidx) => idx !== sidx)
});
}
render() {
return (
<form onSubmit={this.handleSubmit}>
{/* ... */}
<h4>Shareholders</h4>
{this.state.shareholders.map((shareholder, idx) => (
<div className="shareholder">
<input
type="text"
placeholder={`Shareholder #${idx + 1} name`}
value={shareholder.name}
onChange={this.handleShareholderNameChange(idx)}
/>
<button type="button" onClick={this.handleRemoveShareholder(idx)} className="small">-</button>
</div>
))}
<button type="button" onClick={this.handleAddShareholder} className="small">Add Shareholder</button>
<button>Incorporate</button>
</form>
)
}
}
(dev.to doesn't allow to embed the jsbin so follow the link to see it in action)
The code is not perfect, and it doesn't have to be. There are many ways to make it prettier, but this is not a post on refactoring. The code is not the point. Thinking about forms in terms of data is.
I blog about forms in React specifically, and other React-related things.
If you like what you see here, subscribe here to make sure you don't miss out on my next post.
Top comments (0)