Using an array to collect objects is useful. But, it presents some challenges with searching and updating the data. In React, updating an object in an array will not cause re-renders, adding more complication.
This article does not aim to provide a great explanation of what immutability means in this context. This recent post from CSS tricks explains the concept of immutability in JavaScript quite well.
The Problem
An array is an object, and objects are React's state management tracks objects by reference.
const items = [
{ id: 1, label: 'One' },
{ id: 2, label: 'Two' },
{ id: 3, label: 'Three' },
];
return (
<div>
<button onClick={() => items.push({id:5})}>Add Item</button>
{items.map( item => <Item key={item.id} {...item} />)}
</div>
To make this work, we need a new array, not an updated array. Instead of updating the array -- a mutation -- we need to return a new array.
The Solution
I want to avoid clever abstractions on top of array searches and updates that I have. Sorting and searching large collections of objects can become a performance issue. Avoiding an abstraction or dependency helps a bit. So, basically I keep cutting and pasting the same code, so I figured if I put it on dev.to, I could find it via internet search. I'm glad that you may find it useful as well.
These examples work with a collection of objects that use this type:
type field = {
id: string;
label: string;
};
You can use whatever type you want. The searches are based off of the id property.
Immutably Adding Or Adding An Item To An Array
This function makes use of Array.findIndex()
to locate the index of the field being updated in the collection. If it's not present, the item is added to the array. If the item does is found, the existing items in the array are sliced into two -- the items before and the items after -- with the updated item placed in between:
export const addOrUpdateField = (
field: field,
fields: Array<field>
): Array<field> => {
const index = fields.findIndex((f: field) => field.id === f.id);
//Not found, add on end.
if (-1 === index) {
return [...fields, field];
}
//found, so return:
//Clone of items before item being update.
//updated item
//Clone of items after item being updated.
return [...fields.slice(0, index), field, ...fields.slice(index + 1)];
};
Notice that instead of Array.push()
, I'm returning a new array, with the existing items spread in. I can prove that this is returning a different object, with this test:
it('Adds fields immutably', () => {
const intitalFields = addOrUpdateField({ id: '2', label: 'Two' }, []);
const fields = addOrUpdateField({ id: '3', label: 'Three' }, intitalFields);
expect(fields).not.toBe(intitalFields);
});
It is important to me that adding and removing items maintains order, which is why I used Array.slice()
. These tests prove adding and removing works, and maintains the order:
it('Removes field, maintaining order', () => {
const intitalFields = addOrUpdateField({ id: '3', label: 'Three' }, [
{ id: '1', label: 'One' },
{ id: '2', label: 'Two' },
]);
expect(intitalFields.length).toBe(3);
const fields = removeField('2', intitalFields);
expect(fields.length).toBe(2);
expect(fields[0].id).toBe('1');
expect(fields[1].id).toBe('3');
});
it('Adds a field', () => {
let fields = addOrUpdateField({ id: '3', label: 'Three' }, []);
expect(fields[0].id).toBe('3');
});
it('Adds a second field', () => {
const intitalFields = addOrUpdateField({ id: '2', label: 'Two' }, []);
expect(intitalFields[0].id).toBe('2');
const fields = addOrUpdateField({ id: '3', label: 'Three' }, intitalFields);
expect(fields[0].id).toBe('2');
expect(fields[1].id).toBe('3');
});
Immutably Removing An Item From An Array
Ok, one more thing while I'm here, though this could be it's own post: immutably removing item.
This function also relies on Array.findIndex()
. If no item is found, the field collection is returned unmodified. If it is found, I use Array.slice()
to cut the array in two again: items before and items after. This time only those two pieces are returned:
export const removeField = (
fieldId: string,
fields: Array<field>
): Array<field> => {
const index = fields.findIndex((f: field) => fieldId === f.id);
//Not found, return same reference.
if (-1 === index) {
return fields;
}
//Return clone of items before and clone of items after.
return [...fields.slice(0, index), ...fields.slice(index + 1)];
};
I can prove that fields are removed, and order is maintained, with this test:
it('Removes field, maintaining order', () => {
const intitalFields = addOrUpdateField({ id: '3', label: 'Three' }, [
{ id: '1', label: 'One' },
{ id: '2', label: 'Two' },
]);
expect(intitalFields.length).toBe(3);
const fields = removeField('2', intitalFields);
expect(fields.length).toBe(2);
expect(fields[0].id).toBe('1');
expect(fields[1].id).toBe('3');
});
BTW I'm using the addOrUpdateField
function, which does make this an integration test, not a unit test. Also, I don't care. I like this kind of functional programming with arrays.
I care that it works the way I want it to. So I care that it updates immutably when used how I'm actually going to use it:
it('Removes field immutably', () => {
const intitalFields = addOrUpdateField({ id: '3', label: 'Three' }, [
{ id: '1', label: 'One' },
{ id: '2', label: 'Two' },
]);
const fields = removeField('2', intitalFields);
expect(fields).not.toBe(intitalFields);
});
Top comments (5)
That example is massively over simplified, I can see why it's confusing. In my case it does need to be decoupled from the component as the state is shared in multiple components. Also, state management is shared with parts of the page that are not created with React.
Yes, what you show works for adding an item. It would still need an updater method.
Would it be possible to lift the state up to the common ancestor of all those components and then drill it down using props? If not, you may want to reach for Redux.
The updater method is
setItems
.Yes. That would be a good startegy.
What I like about writing state management decoupled from React is I can make that call later. If I have an updateItem, removeItem, and deleteItem, and they have tests, I can choose to use React.useState() or something Redux-like depending on my need.
The first article I've ever read that has unit tests to prove the assertions. Well done!