Arrays in State: Immutable Updates Part 2
Arrays in React state must be treated as immutable—directly mutating them with methods like push(), pop(), or direct assignment prevents React from detecting changes and re-rendering. This guide teaches immutable patterns for adding, removing, and updating array items using the spread operator, filter(), and map(). Mastering these techniques is essential for building interactive lists, todo apps, and data-driven interfaces.
Key Takeaways
- Never mutate state arrays with
push(),pop(),splice(),sort(), or direct index assignment; use spread syntax instead - Add items immutably with
[...array, newItem](end) or[newItem, ...array](beginning) - Remove items with
array.filter(item => item.id !== targetId)to create a new array without the deleted item - Update items with
array.map(item => item.id === targetId ? {...item, field: newValue} : item) - Always pass a new array to the state setter function; React compares references to detect changes
Prerequisites
Before starting, understand:
- JavaScript array methods:
map(),filter(),spread()syntax - Immutability principles (from Part 1 on objects in state)
- The
useStatehook basics - Functional React components and JSX
The Golden Rule: Never Mutate Arrays in State
Just like objects, arrays in React state must be treated as read-only. Methods that mutate the array in place prevent React from detecting changes because the array reference stays the same. Here's the complete reference of what to avoid and what to use instead:
| Avoid (mutates) | Use Instead (new array) |
|---|---|
array.push(item) | [...array, item] |
array.unshift(item) | [item, ...array] |
array.pop() | array.slice(0, -1) |
array.shift() | array.slice(1) |
array.splice(index, count, item) | array.slice(0, index).concat(array.slice(index + count)) or filter() |
array[i] = value | array.map((item, j) => j === i ? value : item) |
array.sort(), array.reverse() | [...array].sort(), [...array].reverse() |
When React compares the previous state to the new state, it checks if the reference changed. If you mutate the original array, the reference is identical, and React won't re-render. Your UI will be out of sync with the actual state.
Common Immutable Array Patterns
Adding Items to an Array
To add an item, use the spread operator to create a new array containing all old items plus the new one:
import React, { useState } from 'react';
let nextId = 0;
function ArtistList() {
const [name, setName] = useState('');
const [artists, setArtists] = useState([]);
function handleAdd() {
// Spread old array, add new item at the end
setArtists([...artists, { id: nextId++, name: name }]);
setName(''); // Clear input
}
return (
<>
<h1>Inspiring Sculptors</h1>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="Artist name"
/>
<button onClick={handleAdd}>Add</button>
<ul>
{artists.map(artist => (
<li key={artist.id}>{artist.name}</li>
))}
</ul>
</>
);
}
export default ArtistList;
Breakdown:
[...artists, { id: nextId++, name: name }]— The spread operator...artistsunpacks all items from the old array, then adds the new artist object at the end- This creates a new array; React detects the reference change and re-renders
- To add to the beginning instead, reverse the order:
[{ id: nextId++, name: name }, ...artists]
Removing Items from an Array
The cleanest way to remove items is filter(), which returns a new array containing only items that pass a test:
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 new array excluding the artist with matching ID
setArtists(artists.filter(a => a.id !== artistId));
}
return (
<ul>
{artists.map(artist => (
<li key={artist.id}>
{artist.name}{' '}
<button onClick={() => handleDelete(artist.id)}>
Delete
</button>
</li>
))}
</ul>
);
}
export default ArtistListWithDelete;
How it works:
- Click "Delete" button →
handleDelete(artistId)is called artists.filter(a => a.id !== artistId)iterates over each artist- Returns a new array with only artists whose
iddoes NOT match the one being deleted setArtists()receives this new array → React re-renders
The beauty of filter() is that it never mutates the original array; it always returns a new one.
Updating Items in an Array
To update one item in an array, use map() to create a new array where the matching item is replaced with an updated version:
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) {
setMyList(myList.map(artwork =>
artwork.id === artworkId
? { ...artwork, seen: nextSeen }
: artwork
));
}
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;
Breakdown:
map()iterates over each artwork and returns a new array- If
artwork.id === artworkId, return a new object with theseenproperty updated:{ ...artwork, seen: nextSeen } - Otherwise, return the original artwork unchanged
- The spread operator
{ ...artwork }creates a shallow copy of the old object with one property overwritten
Practical Example: A Complete Todo List
Here's a real-world example combining add, update, and delete patterns:
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;
setTodos([...todos, { id: nextId++, title: text, done: false }]);
setText('');
}
function handleToggleTodo(todoId, nextDone) {
setTodos(todos.map(todo =>
todo.id === todoId ? { ...todo, done: nextDone } : todo
));
}
function handleDeleteTodo(todoId) {
setTodos(todos.filter(t => t.id !== todoId));
}
return (
<>
<input
value={text}
onChange={e => setText(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleAddTodo()}
placeholder="Add a new todo"
/>
<button onClick={handleAddTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={e => handleToggleTodo(todo.id, e.target.checked)}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.title}
</span>
<button onClick={() => handleDeleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</>
);
}
export default TodoList;
This example demonstrates all three operations:
- Adding:
handleAddTodo()uses[...todos, newTodo] - Updating:
handleToggleTodo()usesmap()to update thedonestatus - Deleting:
handleDeleteTodo()usesfilter()to remove the item
Best Practices
Do:
- Use the spread operator for adding items:
[...array, newItem]or[newItem, ...array] - Use
filter()for removing items:array.filter(item => item.id !== removeId) - Use
map()for updating items:array.map(item => item.id === updateId ? {...item, field: newValue} : item) - Always pass a new array to the state setter
- Use descriptive variable names in map/filter callbacks:
todos.map(todo => ...)nottodos.map(t => ...) - Combine these patterns for complex operations
Don't:
- Never use
array.push(),array.pop(),array.splice(), or direct index assignment - Don't reuse the same object; always create a new one with spread:
{ ...oldObject, changedField: newValue } - Avoid nested mutations: if an array item is an object, don't mutate its properties directly
- Don't sort or reverse in place; copy first:
[...array].sort()notarray.sort()
Frequently Asked Questions
What's the difference between slice() and splice()?
slice(start, end) creates a new array from start to end without mutating the original. splice(start, deleteCount, item) mutates the original array. Always use slice() in React state.
Can I mutate items inside an array if the array reference is new?
No. If your array contains objects and you mutate a property of one of those objects (e.g., array[0].name = 'new'), React may not detect the change because the object reference didn't change. Always create new objects: array[0] = { ...array[0], name: 'new' }.
Is there a performance cost to creating new arrays?
No significant cost for typical app sizes. Modern JavaScript engines optimize array operations, and shallow copying (spreading) is fast. Only worry about performance if you have hundreds of thousands of items, and even then, consider refactoring the data structure first.
How do I update multiple items in an array at once?
Use map() with multiple conditions:
setItems(items.map(item =>
[1, 2, 3].includes(item.id)
? { ...item, completed: true }
: item
));
Do I need to use useCallback for these update functions?
Only if passing them as props to optimized child components with React.memo. For most cases, inline handlers are fine.
Conclusion
Immutable array updates are fundamental to building React applications that work correctly. The three core patterns are:
- Add: Use spread syntax
[...array, newItem]or[newItem, ...array] - Remove: Use
filter()to exclude items:array.filter(item => condition) - Update: Use
map()to replace matching items:array.map(item => match ? {...item, field: value} : item)
Always remember: pass a new array to the state setter, never mutate the original. This ensures React detects changes and re-renders your components correctly.