Updating Arrays in State
Arrays are mutable in JavaScript, but when they live in React state you must treat them as immutable. Just like objects, React detects changes via reference equality — mutating an array in place produces the same reference, so React sees no change and skips the re-render.
The solution is always the same: produce a new array using non-mutating operations, then pass that new array to the setter.
Mutating vs Non-Mutating Methods
Operation | Mutating (avoid) | Non-mutating (use this) |
|---|---|---|
Add to end |
|
|
Add to start |
|
|
Remove by index |
|
|
Remove by value |
|
|
Update an item |
|
|
Sort |
|
|
Reverse |
|
|
Insert at index |
|
|
Adding Items to an Array
import { useState } from 'react'
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Build a project' },
])
const [input, setInput] = useState('')
function addTodo() {
if (!input.trim()) return
// ✓ Spread creates a new array with the new item at the end
setTodos(prev => [
...prev,
{ id: Date.now(), text: input.trim() },
])
setInput('')
}
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo) => <li key={todo.id}>{todo.text}</li>)}
</ul>
</div>
)
}Removing Items from an Array
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React' },
{ id: 2, text: 'Build a project' },
{ id: 3, text: 'Deploy to production' },
])
function removeTodo(id) {
// filter returns a new array excluding the item with the given id
setTodos(prev => prev.filter((todo) => todo.id !== id))
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.text}
<button onClick={() => removeTodo(todo.id)}>✕</button>
</li>
))}
</ul>
)
}Updating an Item in an Array
Use map to produce a new array where one (or more) items are replaced. Items that should not change are returned as-is; the target item is replaced with a new object:
function TaskList() {
const [tasks, setTasks] = useState([
{ id: 1, title: 'Write tests', done: false },
{ id: 2, title: 'Fix bug #42', done: false },
{ id: 3, title: 'Update docs', done: true },
])
function toggleTask(id) {
setTasks(prev =>
prev.map((task) =>
task.id === id
? { ...task, done: !task.done } // new object for the changed item
: task // same reference for unchanged items
)
)
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<input
type="checkbox"
checked={task.done}
onChange={() => toggleTask(task.id)}
/>
<span style={{ textDecoration: task.done ? 'line-through' : 'none' }}>
{task.title}
</span>
</li>
))}
</ul>
)
}Inserting an Item at a Specific Index
function insertAt(arr, index, newItem) {
return [
...arr.slice(0, index), // everything before the insertion point
newItem, // the new item
...arr.slice(index), // everything from the insertion point onwards
]
}
// Usage:
setItems(prev => insertAt(prev, 2, { id: Date.now(), text: 'New middle item' }))Sorting Arrays in State
Array.sort() mutates in place. Create a copy first with the spread operator, then sort the copy:
function SortableList() {
const [items, setItems] = useState([
{ id: 1, name: 'Banana' },
{ id: 2, name: 'Apple' },
{ id: 3, name: 'Cherry' },
])
function sortAZ() {
// ✗ WRONG — sorts the state array in place; no new reference
// setItems(items.sort((a, b) => a.name.localeCompare(b.name)))
// ✓ CORRECT — spread creates a new array, then sort
setItems(prev => [...prev].sort((a, b) => a.name.localeCompare(b.name)))
}
function sortZA() {
setItems(prev => [...prev].sort((a, b) => b.name.localeCompare(a.name)))
}
return (
<div>
<button onClick={sortAZ}>A → Z</button>
<button onClick={sortZA}>Z → A</button>
<ul>
{items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
)
}Complex Array State with Immer
When array operations get complex — deeply nested items, reordering with multiple steps, conditional updates across many items — Immer removes the boilerplate:
import { useImmer } from 'use-immer'
function KanbanBoard() {
const [cards, updateCards] = useImmer([
{ id: 1, title: 'Design', column: 'todo', priority: 'high' },
{ id: 2, title: 'Implement', column: 'todo', priority: 'medium' },
{ id: 3, title: 'Review', column: 'done', priority: 'low' },
])
function moveToInProgress(id) {
updateCards(draft => {
const card = draft.find((c) => c.id === id)
if (card) card.column = 'in-progress' // direct mutation on the draft ✓
})
}
function setPriority(id, priority) {
updateCards(draft => {
const card = draft.find((c) => c.id === id)
if (card) card.priority = priority
})
}
return (/* ... */)
}Full CRUD Example
import { useState } from 'react'
let nextId = 4
function ShoppingList() {
const [items, setItems] = useState([
{ id: 1, name: 'Apples', qty: 3 },
{ id: 2, name: 'Bread', qty: 1 },
{ id: 3, name: 'Milk', qty: 2 },
])
// CREATE
function addItem(name) {
setItems(prev => [...prev, { id: nextId++, name, qty: 1 }])
}
// UPDATE
function updateQty(id, qty) {
setItems(prev =>
prev.map((item) => item.id === id ? { ...item, qty } : item)
)
}
// DELETE
function removeItem(id) {
setItems(prev => prev.filter((item) => item.id !== id))
}
return (
<ul>
{items.map((item) => (
<li key={item.id}>
{item.name}
<input
type="number"
value={item.qty}
min={1}
onChange={(e) => updateQty(item.id, Number(e.target.value))}
/>
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
)
}Never call mutating methods (
push,pop,splice,sort,reverse) directly on state arraysAdd items with
[...prev, newItem]or[newItem, ...prev]Remove items with
filterUpdate items with
map— return a new object for the changed item, the original for all othersSort/reverse by spreading first:
[...prev].sort(...)Immer (
use-immer) is worth it for complex array-of-objects state