Runnable lesson · press Try it on the examples to edit and run them here.
Arrays in state follow the same rule as objects: never change them in place. Create a new array and pass it to the setter. The trick is knowing which array methods are safe.
Safe and unsafe methods
| Action | Avoid (mutates) | Prefer (returns a new array) |
|---|---|---|
| Add | push, unshift | [...arr, item], [item, ...arr], arr.concat(item) |
| Remove | pop, shift, splice | arr.filter(...), arr.slice(...) |
| Replace | arr[i] = x | arr.map(...) |
| Sort / reverse | sort, reverse | Copy first: [...arr].sort(), or arr.toSorted() |
| Insert | splice | [...arr.slice(0, i), item, ...arr.slice(i)] |
(toSorted, toReversed, toSpliced and with are newer non-mutating methods; check feature support if you must support older browsers.) See also Immutable Array Methods.
Add, remove and update: a todo list
This example uses every pattern:
function App() {
const [todos, setTodos] = React.useState([
{ id: 1, text: "Learn React", done: false },
{ id: 2, text: "Walk dog", done: false },
]);
const [text, setText] = React.useState("");
function addTodo() {
if (!text.trim()) return;
// ADD: a new array with the old items plus the new one
setTodos([...todos, { id: Date.now(), text: text.trim(), done: false }]);
setText("");
}
function toggleTodo(id) {
// REPLACE one item: map, copy only the item that changes
setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
function removeTodo(id) {
// REMOVE: keep everything except that id
setTodos(todos.filter((t) => t.id !== id));
}
const doneCount = todos.filter((t) => t.done).length;
return (
<div>
<input aria-label="New todo" value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={addTodo}>Add</button>
<p>Todos: {todos.length}, Done: {doneCount}</p>
<ul>
{todos.map((t) => (
<li key={t.id}>
<span style={{ textDecoration: t.done ? "line-through" : "none" }}>{t.text}</span>{" "}
<button onClick={() => toggleTodo(t.id)} aria-label={"Toggle " + t.text}>Toggle {t.text}</button>{" "}
<button onClick={() => removeTodo(t.id)} aria-label={"Remove " + t.text}>Remove {t.text}</button>
</li>
))}
</ul>
</div>
);
}
Each setter call builds a new array, and toggleTodo also builds a new object for the one item that changed ({ ...t, done: !t.done }). The other items are reused as they are.
Nested objects in arrays
Because spread is shallow, { ...t } copies the todo but shares any nested objects. If a todo had a tags array, changing it would need { ...t, tags: [...t.tags, "new"] }.
Inserting at a position
function App() {
const [letters, setLetters] = React.useState(["a", "b", "d"]);
function insertC() {
const index = 2;
setLetters([...letters.slice(0, index), "c", ...letters.slice(index)]);
}
return (
<div>
<p>{letters.join(", ")}</p>
<button onClick={insertC}>Insert c</button>
</div>
);
}
Reordering and sorting
function App() {
const [nums, setNums] = React.useState([3, 1, 2]);
return (
<div>
<p>{nums.join(", ")}</p>
<button onClick={() => setNums([...nums].sort((a, b) => a - b))}>Sort</button>
</div>
);
}
Spread first ([...nums]) and then sort the copy. nums.sort(...) would sort the state array in place.
Do you need to store the sorted or filtered version?
No. Keep the original array in state and derive the sorted or filtered list while rendering, as shown in Filtering and Sorting Lists.
Key points
- Add with spread, remove with
filter, update withmap, and sort a copy. - Replace only the objects that change; reuse the rest.
- Use ids, not array positions, to say which item to change.