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()
, andsplice()
. - ✅ 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:
[...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.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'sid
. artists.filter(a => a.id !== artistId)
iterates over the currentartists
array. For each artista
, it checks if theirid
is not equal to theartistId
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 theid
of the item to change and its newseen
status. - We
map
over themyList
array. For eachartwork
, we check if itsid
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 theseen
property withnextSeen
. - If the
id
doesn't match, we simply return the originalartwork
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
usesmap
to find the correct todo item and replace it with an updated version. - Deleting:
handleDeleteTodo
usesfilter
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.