Skip to main content

Working with State: Objects and Arrays (Part 2) #54

📖 Introduction

In our previous article, we learned the crucial principle of immutability when updating objects in state. That same principle applies just as strongly to arrays. Because arrays in JavaScript are mutable, directly changing them in React state can lead to bugs and prevent your components from re-rendering correctly.

This article will guide you through the standard immutable patterns for working with arrays in React state. You'll learn how to add, update, and remove items from an array without ever breaking the rules of immutability.


📚 Prerequisites

Before we begin, please ensure you have a solid grasp of the following concepts:

  • JavaScript array methods like map(), filter(), and the spread (...) syntax.
  • The concept of immutability from the previous article.
  • The useState hook.

🎯 Article Outline: What You'll Master

In this article, you will learn:

  • The Core Rule: Why you must avoid mutating array methods like push(), pop(), and splice().
  • Adding to an Array: How to immutably add items to the beginning or end of an array.
  • Removing from an Array: The correct way to remove items using the filter() method.
  • Transforming an Array: How to update one or more items in an array using the map() method.
  • Practical Application: Building an interactive Todo List that combines all these patterns.

🧠 Section 1: The Golden Rule - Don't Mutate the Array

Just like with objects, you must treat arrays in React state as read-only. Methods that change the array in place are forbidden.

Avoid (mutates array)Prefer (returns a new array)
push(), pop(), shift(), unshift()[...arr, newItem] (spread syntax)
splice()filter(), slice()
sort(), reverse()First, copy the array: [...arr].sort()
arr[i] = ... (direct assignment)map()

Violating this rule means you are changing the original array. When React compares the previous state to the next state, it will see the same array reference and won't "know" that it needs to re-render your component, leaving your UI out of sync.


💻 Section 2: Common Patterns for Immutable Array Updates

Let's walk through the most common operations you'll perform on arrays in state.

2.1 - Adding Items to an Array

To add an item, create a new array by spreading the old array's items and adding the new item.

// code-block-1.jsx
import React, { useState } from 'react';

let nextId = 0;

function ArtistList() {
const [name, setName] = useState('');
const [artists, setArtists] = useState([]);

function handleAdd() {
// Create a new array with the new item at the end
const newArtists = [...artists, { id: nextId++, name: name }];
setArtists(newArtists);
setName(''); // Clear the input field
}

return (
<>
<h1>Inspiring sculptors:</h1>
<input
value={name}
onChange={e => setName(e.target.value)}
/>
<button onClick={handleAdd}>Add</button>
<ul>
{artists.map(artist => (
<li key={artist.id}>{artist.name}</li>
))}
</ul>
</>
);
}

export default ArtistList;

Step-by-Step Code Breakdown:

  1. [...artists, { id: nextId++, name: name }]: This is the core of the operation. The ...artists part creates a shallow copy of all the items from the old array. Then, we add our new artist object at the end.
  2. setArtists(newArtists): We call the state setter with this entirely new array, which correctly triggers a re-render.

To add an item to the beginning of the array, you would simply reverse the order: [{ id: nextId++, name: name }, ...artists].

2.2 - Removing Items from an Array

The cleanest way to remove an item is to filter() it out. The filter method doesn't change the original array; it returns a new array containing only the items that pass the test.

// code-block-2.jsx
import React, { useState } from 'react';

const initialArtists = [
{ id: 0, name: 'Marta Colvin Andrade' },
{ id: 1, name: 'Lamidi Olonade Fakeye'},
{ id: 2, name: 'Louise Nevelson'},
];

function ArtistListWithDelete() {
const [artists, setArtists] = useState(initialArtists);

function handleDelete(artistId) {
// Create a new array that excludes the artist with the matching ID.
const newArtists = artists.filter(a => a.id !== artistId);
setArtists(newArtists);
}

return (
<ul>
{artists.map(artist => (
<li key={artist.id}>
{artist.name}{' '}
<button onClick={() => handleDelete(artist.id)}>
Delete
</button>
</li>
))}
</ul>
);
}

export default ArtistListWithDelete;

Walkthrough:

  • When you click "Delete", the handleDelete function is called with that artist's id.
  • artists.filter(a => a.id !== artistId) iterates over the current artists array. For each artist a, it checks if their id is not equal to the artistId we want to remove.
  • It returns a new array containing only the artists that passed the test (i.e., all artists except the one we deleted).
  • setArtists receives this new array, and React re-renders the list.

2.3 - Transforming Items in an Array

When you need to change an item in an array, you use the map() method. map() also returns a new array, allowing you to create a modified version of an item while keeping the others the same.

Let's create a list where we can mark items as "seen".

// code-block-3.jsx
import React, { useState } from 'react';

const initialList = [
{ id: 0, title: 'Big Bellies', seen: false },
{ id: 1, title: 'Lunar Landscape', seen: false },
{ id: 2, title: 'Terracotta Army', seen: true },
];

function BucketList() {
const [myList, setMyList] = useState(initialList);

function handleToggle(artworkId, nextSeen) {
const newMyList = myList.map(artwork => {
// If this is the artwork we want to change...
if (artwork.id === artworkId) {
// ...create a new object with the updated 'seen' value.
return { ...artwork, seen: nextSeen };
} else {
// Otherwise, return the original, unchanged artwork object.
return artwork;
}
});
setMyList(newMyList);
}

return (
<ItemList artworks={myList} onToggle={handleToggle} />
);
}

function ItemList({ artworks, onToggle }) {
return (
<ul>
{artworks.map(artwork => (
<li key={artwork.id}>
<label>
<input
type="checkbox"
checked={artwork.seen}
onChange={e => {
onToggle(artwork.id, e.target.checked);
}}
/>
{artwork.title}
</label>
</li>
))}
</ul>
);
}

export default BucketList;

Walkthrough:

  • The handleToggle function receives the id of the item to change and its new seen status.
  • We map over the myList array. For each artwork, we check if its id matches.
  • If it matches, we return a new object. We use the spread syntax { ...artwork } to copy the properties of the old item and then overwrite the seen property with nextSeen.
  • If the id doesn't match, we simply return the original artwork object, unchanged.
  • The map function returns a new array containing the mix of original and updated items, which we use to set the state.

🛠️ Section 4: Project-Based Example: A Simple Todo List

Let's combine these patterns to build a classic Todo List application.

// project-example.jsx
import React, { useState } from 'react';

let nextId = 3;
const initialTodos = [
{ id: 0, title: 'Buy milk', done: true },
{ id: 1, title: 'Eat tacos', done: false },
{ id: 2, title: 'Brew tea', done: false },
];

function TodoList() {
const [text, setText] = useState('');
const [todos, setTodos] = useState(initialTodos);

function handleAddTodo() {
if (!text.trim()) return; // Don't add empty todos
setTodos([...todos, { id: nextId++, title: text, done: false }]);
setText('');
}

function handleChangeTodo(nextTodo) {
setTodos(todos.map(t => {
if (t.id === nextTodo.id) {
return nextTodo;
} else {
return t;
}
}));
}

function handleDeleteTodo(todoId) {
setTodos(todos.filter(t => t.id !== todoId));
}

return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={handleAddTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={e => {
handleChangeTodo({ ...todo, done: e.target.checked });
}}
/>
{todo.title}
<button onClick={() => handleDeleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</>
);
}

export default TodoList;

This example is a perfect summary of our patterns:

  • Adding: handleAddTodo uses the spread syntax [...todos, newTodo] to add a new item.
  • Updating: handleChangeTodo uses map to find the correct todo item and replace it with an updated version.
  • Deleting: handleDeleteTodo uses filter to create a new array without the deleted item.

💡 Conclusion & Key Takeaways

Correctly handling array updates is fundamental to building stable and predictable React applications. By always treating state as immutable and using non-mutating methods, you avoid a large category of common bugs.

Let's summarize the key takeaways:

  • Adding: Use [...array, newItem] to add to the end, or [newItem, ...array] to add to the beginning.
  • Removing: Use array.filter() to create a new array that excludes the items you want to remove.
  • Updating: Use array.map() to create a new array where specific items have been replaced with updated versions.
  • The Core Principle: Always give the useState setter function a new array.

Challenge Yourself: In the TodoList example, add a button to each <li> that allows you to edit the todo's title. You will need to add another piece of state to manage which item is currently being edited.


➡️ Next Steps

You now have a solid foundation for managing both objects and arrays in state. In the next article, "The Setter Function: Functional Updates", we will take a closer look at the updater function form (setCount(c => c + 1)) and understand why it's the safest way to update state that depends on previous values.

Thank you for your dedication. Stay curious, and happy coding!


glossary

  • Immutable Update: The process of updating state by creating a new copy of the data (a new array or object) with the changes, rather than modifying the original data in place.
  • map(): An array method that creates a new array by calling a function on every element of the original array and returning the results.
  • filter(): An array method that creates a new array with all elements that pass the test implemented by the provided function.

Further Reading