Updating Arrays in State
Lists of data — todos, comments, search results, cart items — are usually stored as arrays in component state. React re-renders a component when its state changes, but it decides whether something changed by comparing the old and new state values, not by inspecting their contents. That means every time you update an array in state, you must produce a brand-new array rather than modifying the existing one in place. Get this wrong and your UI silently stops updating, or updates for the wrong reasons. This lesson covers the full, correct pattern for adding, removing, updating, and reordering items in array state.
Overview / How It Works
When you call useState with an array, React stores a reference to that array internally and gives you a setter function, conventionally named setSomething. On every render, React compares the array you pass to the setter against the previous state using Object.is — essentially a reference equality check, the same as ===. If you mutate the array directly (for example with push, splice, or by setting array[0] = x) and then pass that same array back into the setter, the reference hasn’t changed at all. React sees the identical object it already has and may decide nothing changed, skipping the re-render entirely — even though the array’s contents are different. This is one of the most common sources of “my state updated but the screen didn’t” bugs in React.
The fix is to always treat state arrays as immutable: never modify them, always build a new array (or new object, for arrays of objects) that reflects the change you want, and pass that new value to the setter. React then sees a new reference, schedules a re-render, walks the component tree (the “render” phase), diffs the new Virtual DOM against the previous one (“reconciliation”), and applies only the minimal necessary changes to the real DOM (the “commit” phase). None of this works correctly if the old and new arrays are secretly the same object.
JavaScript’s built-in array methods split cleanly into two camps. Non-mutating methods like map, filter, slice, concat, and the spread operator (...) all return a new array and leave the original untouched — these are what you want in React. Mutating methods like push, pop, shift, unshift, splice, sort, and reverse change the array in place and return either the mutated array or some other value — avoid calling these directly on state.
| Avoid (mutates in place) | Use instead (returns a new array) |
|---|---|
arr.push(item) |
[...arr, item] |
arr.pop() |
arr.slice(0, -1) |
arr.shift() |
arr.slice(1) |
arr.unshift(item) |
[item, ...arr] |
arr.splice(i, 1) |
arr.filter((_, idx) => idx !== i) |
arr.sort() / arr.reverse() |
[...arr].sort() / [...arr].reverse() |
arr[i] = newValue |
arr.map((v, idx) => idx === i ? newValue : v) |
Syntax
The general shape for the three most common array operations looks like this:
setItems(prevItems => [...prevItems, newItem]);
// remove
setItems(prevItems => prevItems.filter(item => item.id !== idToRemove));
// update one item, leave the rest alone
setItems(prevItems =>
prevItems.map(item => (item.id === idToUpdate ? { ...item, ...changes } : item))
);
- Add: spread the previous array and append (or prepend) the new item — this creates a new array containing every old item plus the new one.
- Remove: use
filterto keep every item except the one matching some condition (usually an id) —filteralways returns a new array. - Update: use
mapto walk every item, returning a changed copy for the matching item and the original item unchanged for everything else. - Using the updater-function form (
prevItems =>…) instead of referencing the outeritemsvariable directly avoids bugs when multiple updates happen in quick succession, since each updater always receives the latest state.
Examples
Example 1: Adding items to an array
import { useState } from "react";
function NumberList() {
const [numbers, setNumbers] = useState([1, 2, 3]);
function handleAdd() {
const next = numbers.length + 1;
setNumbers([...numbers, next]);
}
return (
<div>
<button onClick={handleAdd}>Add Number</button>
<ul>
{numbers.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
</div>
);
}
export default NumberList;
Output:
Renders a button labeled "Add Number" and a list showing 1, 2, 3.
Each click appends the next number (4, then 5, then 6, ...) to the bottom of the list.
Every click builds a brand-new array with [...numbers, next] instead of pushing onto the existing one. The key on each <li> uses the number itself since these values are unique and stable here; for real-world data, prefer a stable id rather than an array index.
Example 2: Removing an item from an array
import { useState } from "react";
function FruitList() {
const [fruits, setFruits] = useState([
{ id: 1, name: "Apple" },
{ id: 2, name: "Banana" },
{ id: 3, name: "Cherry" },
]);
function handleRemove(id) {
setFruits(fruits.filter((fruit) => fruit.id !== id));
}
return (
<ul>
{fruits.map((fruit) => (
<li key={fruit.id}>
{fruit.name}{" "}
<button onClick={() => handleRemove(fruit.id)}>Remove</button>
</li>
))}
</ul>
);
}
export default FruitList;
Output:
Renders three list items: "Apple", "Banana", "Cherry", each with its own Remove button.
Clicking Banana's Remove button leaves only "Apple" and "Cherry" rendered.
filter keeps every fruit whose id does not match the one clicked, producing a new array without the removed fruit. The original fruits array in the previous state is never touched.
Example 3: A realistic todo list — add, toggle, and remove
import { useState } from "react";
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React", done: false },
{ id: 2, text: "Build a project", done: false },
]);
const [input, setInput] = useState("");
function handleAdd(e) {
e.preventDefault();
if (input.trim() === "") return;
const newTodo = { id: Date.now(), text: input, done: false };
setTodos([...todos, newTodo]);
setInput("");
}
function handleToggle(id) {
setTodos(
todos.map((todo) =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}
function handleRemove(id) {
setTodos(todos.filter((todo) => todo.id !== id));
}
return (
<div>
<form onSubmit={handleAdd}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Add a todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span onClick={() => handleToggle(todo.id)}>
{todo.done ? "\u2705 " : "\u2b1c "}
{todo.text}
</span>{" "}
<button onClick={() => handleRemove(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default TodoApp;
Output:
Renders a text input, an Add button, and two todo items ("Learn React", "Build a project"),
each prefixed with an empty checkbox icon and followed by a Delete button.
Typing text and clicking Add appends a new todo to the bottom of the list and clears the input.
Clicking a todo's text toggles its icon between empty and checked without affecting the other todos.
Clicking Delete removes only that one todo from the rendered list.
This example combines all three operations. Adding spreads the old array and appends a new object built from the input plus a fresh id. Toggling uses map to return a new object (via spread plus an overridden done field) only for the matching todo, leaving every other object reference untouched — this matters because unrelated list items don’t need to re-render. Removing uses filter exactly as before.
How It Works Step by Step / Under the Hood
- On mount:
useState([...])stores the initial array. React renders the component, producing a list of elements, each identified in the tree by itskey. - On a state update: calling the setter with a new array reference schedules a re-render. React re-runs the component function, producing a new list of JSX elements. It then compares the new list against the previous one, matching elements by their
keyrather than by position. An element whose key already existed is reused and only patched with prop changes; an element whose key is new is mounted; an element whose key disappeared is unmounted. This is why removing item id 2 correctly removes only that DOM node instead of shifting every subsequent item’s content — as long as keys are stable ids, not array indexes. - Object.is comparison: before doing any of that work, React first checks whether the new state value is reference-equal to the old one. If you mutated in place and passed back the same array, this check reports “unchanged” and React can bail out of re-rendering that component entirely — the exact bug this lesson exists to prevent.
- On unmount: when an item’s key disappears from the array (because it was filtered out), React removes its subtree from the DOM and runs any cleanup functions from that item’s effects, if it had its own component with effects.
Common Mistakes
Mistake 1: Mutating with push before calling the setter
function handleAdd() {
items.push(newItem); // mutates the existing array in place
setItems(items); // same reference as before -- React may skip the re-render
}
Because items still points to the same array object, setItems receives a reference that is Object.is-equal to the current state. React can conclude nothing changed and skip re-rendering, so the new item may never appear on screen even though it’s technically in the array.
function handleAdd() {
setItems([...items, newItem]); // new array reference
}
Mistake 2: Sorting or reversing state in place
function handleSort() {
items.sort(); // mutates the array that state currently points to
setItems(items);
}
sort and reverse both mutate the array they’re called on and return that same array — they don’t create a copy. Calling them directly on your state array corrupts the “previous” state that other parts of your code (or React DevTools time-travel, or a future comparison) might still be relying on, in addition to the same reference-equality problem as above.
function handleSort() {
setItems([...items].sort()); // sort a copy, keep the original untouched
}
Mistake 3: Mutating a nested object inside an array
function handleRename(id, newName) {
const updated = [...users]; // new outer array...
const user = updated.find((u) => u.id === id);
user.name = newName; // ...but this still mutates the original nested object
setUsers(updated);
}
Spreading the outer array only copies the array itself — it’s a shallow copy, so every object inside it is still the exact same object reference as before. Mutating user.name here changes the object that both the old and new arrays point to, which can cause subtle bugs if anything compared the old object reference expecting it to be unchanged.
function handleRename(id, newName) {
setUsers(
users.map((u) => (u.id === id ? { ...u, name: newName } : u))
);
}
Best Practices
- Always produce a new array from a state update: use spread,
map,filter,slice, orconcatinstead ofpush,pop,splice,sort, orreverse. - When updating one object inside an array of objects, spread both the array and the object: copy the outer array with
mapand copy the target object with{ ...item, changedField }. - Use a stable, unique id (not the array index) for the
keyprop whenever the list can be reordered, filtered, or have items inserted/removed — index keys can cause React to mismatch state between list items after a reorder. - Prefer the updater-function form of the setter (
setItems(prev => ...)) when the next value depends on the previous one, especially inside event handlers that might fire multiple times quickly. - If you need to sort or reverse for display only, do it on a copy at render time (or in a
useMemo) rather than mutating the stored state. - For deeply nested state (arrays of arrays, or arrays of objects with nested objects), consider flattening your data shape or reaching for
useReduceronce the update logic grows complex — deeply nested spreads become hard to read and easy to get wrong.
Practice Exercises
- Build a
ShoppingCartcomponent with an array of{ id, name, quantity }items in state. Add buttons to increase and decrease a specific item’s quantity by 1 without mutating state (hint: usemapand check the id). - Starting from the
TodoAppexample, add an “edit” feature: clicking an Edit button turns that todo’s text into an editable input, and saving updates just that todo’stextfield in the array. - Given an array of scores in state, add a button that sorts them from highest to lowest for display, without mutating the original state array. Verify your solution still works correctly if the user then adds a new score afterward.
Summary
- React decides whether to re-render by comparing state references, so array state must always be replaced with a new array, never mutated in place.
- Use
[...arr, item]to add,arr.filter(...)to remove, andarr.map(...)to update one item while leaving the rest as-is. - Avoid
push,pop,shift,unshift,splice,sort, andreversedirectly on state arrays — copy first if you need them. - For arrays of objects, remember the copy is shallow: also spread the individual object you’re changing, not just the outer array.
- Give list items stable, unique keys (ids, not indexes) so React can correctly match old and new elements during reconciliation.
