DEV Community

loading...
Cover image for How To Immutably Update An Array In TypeScript

How To Immutably Update An Array In TypeScript

shelob9 profile image Josh Pollock ・4 min read

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);
  });

Discussion (6)

pic
Editor guide
Collapse
aleksandrhovhannisyan profile image
Aleksandr Hovhannisyan • Edited

I'm confused. Why are you not treating items as part of this component's state? If it were part of the state, then any changes you made to it would in fact trigger a re-render. By default, this is also how React handles immutability since you are discouraged from modifying state directly.

import React, { useState } from "react";
import "./styles.css";

export default function App() {
  const [items, setItems] = useState([
    { id: 1, label: "One" },
    { id: 2, label: "Two" },
    { id: 3, label: "Three" }
  ]);

  return (
    <div>
      <button onClick={() => setItems([...items, { id: 5, label: 'Five' }])}>Add Item</button>
      {items.map(item => (
        <div key={item.id}>{item.label}</div>
      ))}
    </div>
  );
}
Collapse
shelob9 profile image
Josh Pollock Author

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.

Collapse
aleksandrhovhannisyan profile image
Aleksandr Hovhannisyan

as the state is shared in multiple components

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.

Thread Thread
shelob9 profile image
Josh Pollock Author

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.

Thread Thread
aleksandrhovhannisyan profile image
Aleksandr Hovhannisyan

And tbf, I absolutely hate Redux. It's a PITA to work with.

Collapse
chandlerbaskins profile image
Chandler Baskins

The first article I've ever read that has unit tests to prove the assertions. Well done!