React 19 ⚛️ Updating Arrays in State
Arrays in React state follow the same rule as objects: they are updated by replacement, not by mutation. The array methods that modify in place — push, pop, splice, sort, reverse, and direct index assignment — do not trigger a re-render, because they leave the array reference unchanged. React compares the new state to the old with Object.is, sees the same reference, and skips the update. The methods that produce a new array — concat, slice, map, filter, and the spread operator — create a new reference, which React detects and re-renders.
The practical consequence is that the array methods fall into two categories: the mutating methods that must be avoided, and the non-mutating methods that are the correct tools for the job. Adding an element uses the spread, removing uses filter, updating uses map, and inserting or reordering uses slice or the newer toSpliced, toSorted, and toReversed methods from ES2023. Each produces a new array, and each is a direct replacement for a mutating method.
This chapter covers the immutability rule for arrays, the mutation-versus-replacement distinction, the standard operations of add, remove, update, insert, and reorder, the ES2023 non-mutating methods, arrays of objects, and the mistakes that break the update.
Key point: Never mutate an array in state. Use the spread operator to add, filter to remove, map to update, and slice or toSpliced to insert. The ES2023 methods toSorted, toReversed, and toSpliced are non-mutating versions of sort, reverse, and splice. Use the functional form of the setter when the new array depends on the previous one: setItems(items => [...items, newItem]).
Why arrays are updated by replacement
The reference comparison problem. React detects state changes by comparing references with Object.is. A mutating method changes the array’s contents but not its reference. React sees the same reference, concludes the state has not changed, and skips the re-render. The UI does not update.
The immutability problem. An array in state may be referenced by other parts of the component tree. Mutating it changes the data under every reference, which produces inconsistent UI: some components see the new data, others hold the old reference and do not re-render. Replacement ensures every reference points to a version that matches the render.
The memoization problem. React.memo and useMemo compare props and dependencies by reference. A mutated array keeps the same reference, so memoized computations are not re-run even though the contents changed. A new array triggers the re-computation.
The history problem. If the state holds a history of array versions, mutating in place destroys the previous versions. Replacement preserves them, which makes undo and time-travel debugging possible.
The predictability problem. The non-mutating methods are functions that take an array and return a new one. They compose, they are testable in isolation, and their behavior does not depend on the state of the original array. The mutating methods modify shared state, which makes them harder to reason about.
a. The immutability rule
An array in state is not modified. When an element changes, a new array is created.
const [items, setItems] = useState([1, 2, 3]);
// Wrong: mutation
items.push(4);
setItems(items); // same reference; no re-render
// Right: new array
setItems([...items, 4]); // new reference; re-render
The wrong version mutates the array and passes the same reference to the setter. React sees the same reference and skips the re-render. The right version creates a new array with the spread operator, and the new reference triggers the re-render.
The same applies to every mutating method: push, pop, shift, unshift, splice, sort, reverse, and direct index assignment.
// All wrong: mutation
items.push(x);
items.pop();
items.splice(0, 1);
items.sort();
items.reverse();
items[0] = x;
Each of these modifies the existing array. The replacement patterns that follow produce new arrays and are the correct tools.
b. Adding elements
Adding an element creates a new array with the existing elements plus the new one.
Append to the end.
setItems((items) => [...items, newItem]);
Prepend to the beginning.
setItems((items) => [newItem, ...items]);
Insert at an index.
setItems((items) => [
...items.slice(0, index),
newItem,
...items.slice(index),
]);
The slice-based insertion splits the array at the index, inserts the new item, and concatenates the two halves. The ES2023 toSpliced method does the same thing in one call:
setItems((items) => items.toSpliced(index, 0, newItem));
Add multiple elements.
setItems((items) => [...items, ...newItems]);
Each pattern uses the spread or a non-mutating method. None of them modifies the original array.
c. Removing elements
Removing an element creates a new array without the removed item.
Remove by value.
setItems((items) => items.filter((item) => item !== value));
Remove by index.
setItems((items) => items.filter((_, i) => i !== index));
Remove by predicate.
setItems((items) => items.filter((item) => item.id !== id));
The filter method returns a new array containing only the elements that satisfy the predicate. The original array is unchanged. This is the standard pattern for removing elements.
Remove from the end.
setItems((items) => items.slice(0, -1));
Remove from the beginning.
setItems((items) => items.slice(1));
The slice method returns a new array with the specified range. It does not modify the original.
Remove one element at an index with toSpliced.
setItems((items) => items.toSpliced(index, 1));
The toSpliced method returns a new array with the specified elements removed.
d. Updating elements
Updating an element creates a new array with the updated item in place of the old one.
Update by index.
setItems((items) =>
items.map((item, i) => (i === index ? newValue : item))
);
Update by predicate.
setItems((items) =>
items.map((item) =>
item.id === id ? { ...item, done: true } : item
)
);
The map method returns a new array with the same length. For the element that changes, a new value is produced. For the other elements, the same value is passed through.
Update all elements.
setItems((items) => items.map((item) => ({ ...item, done: true })));
Update with a function.
setItems((items) => items.map((item) => (item.id === id ? f(item) : item)));
The map pattern is the standard way to update array elements. It is non-mutating, it preserves the order, and it produces a new array.
e. Reordering elements
Sorting and reversing in place are mutating operations. The non-mutating replacements are the ES2023 methods toSorted and toReversed, or a slice followed by the mutating method on the copy.
Sort.
// Wrong: mutation
setItems(items.sort());
// Right: slice then sort
setItems([...items].sort());
// Right: ES2023
setItems(items.toSorted());
Reverse.
// Wrong: mutation
setItems(items.reverse());
// Right: slice then reverse
setItems([...items].reverse());
// Right: ES2023
setItems(items.toReversed());
Move an element.
setItems((items) => {
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
});
The move pattern copies the array with the spread, then uses the mutating splice on the copy. The original array is untouched, and the copy is returned as the new state.
The ES2023 methods toSorted, toReversed, and toSpliced are the standard replacements for sort, reverse, and splice when the original must not be modified. They are supported in all modern browsers and Node.js 20 and later.
f. Arrays of objects
An array of objects is updated with the same patterns, with one additional step: the object that changes is also replaced with a new object.
Update one item.
setItems((items) =>
items.map((item) =>
item.id === id ? { ...item, done: true } : item
)
);
The array is new because of map, and the changed item is new because of the spread. The unchanged items keep their references, which lets memoized components skip them.
Add an item.
setItems((items) => [...items, newItem]);
Remove an item.
setItems((items) => items.filter((item) => item.id !== id));
Replace an item.
setItems((items) =>
items.map((item) => (item.id === id ? replacement : item))
);
Toggle a boolean property.
setItems((items) =>
items.map((item) =>
item.id === id ? { ...item, done: !item.done } : item
)
);
Each pattern creates a new array, and the patterns that update an item also create a new object for that item.
Complete Example Session
// ============================================
// PART 1: WRONG — MUTATION
// ============================================
function TodoList() {
const [items, setItems] = useState([1, 2, 3]);
const addItem = () => {
items.push(4);
setItems(items); // same reference; no re-render
};
return <button onClick={addItem}>{items.length}</button>;
}
// ============================================
// PART 2: RIGHT — NEW ARRAY
// ============================================
function TodoList() {
const [items, setItems] = useState([1, 2, 3]);
const addItem = () => {
setItems([...items, 4]);
};
return <button onClick={addItem}>{items.length}</button>;
}
// ============================================
// PART 3: FUNCTIONAL FORM
// ============================================
function TodoList() {
const [items, setItems] = useState([1, 2, 3]);
const addItem = () => {
setItems((prev) => [...prev, 4]);
};
return <button onClick={addItem}>{items.length}</button>;
}
// ============================================
// PART 4: PREPEND
// ============================================
setItems((items) => [newItem, ...items]);
// ============================================
// PART 5: INSERT AT INDEX
// ============================================
setItems((items) => [
...items.slice(0, index),
newItem,
...items.slice(index),
]);
// ============================================
// PART 6: REMOVE BY PREDICATE
// ============================================
setItems((items) => items.filter((item) => item.id !== id));
// ============================================
// PART 7: UPDATE ONE ITEM
// ============================================
setItems((items) =>
items.map((item) =>
item.id === id ? { ...item, done: true } : item
)
);
// ============================================
// PART 8: SORT WITH TOSORTED
// ============================================
setItems((items) => items.toSorted((a, b) => a.name.localeCompare(b.name)));
// ============================================
// PART 9: REVERSE WITH TOREVERSED
// ============================================
setItems((items) => items.toReversed());
// ============================================
// PART 10: MOVE AN ELEMENT
// ============================================
setItems((items) => {
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
});
These ten parts cover the wrong mutation, the right new array, the functional form, prepend, insert at index, remove by predicate, update one item, sort with toSorted, reverse with toReversed, and moving an element.
Quick Reference
Mutating vs Non-Mutating Methods
| Mutating | Non-Mutating |
|---|---|
push | [...arr, item] |
unshift | [item, ...arr] |
pop | arr.slice(0, -1) |
shift | arr.slice(1) |
splice | toSpliced, spread with slices |
sort | toSorted, [...arr].sort() |
reverse | toReversed, [...arr].reverse() |
arr[i] = x | arr.map((v, j) => j === i ? x : v) |
Common Operations
| Operation | Pattern |
|---|---|
| Add to end | [...items, newItem] |
| Add to start | [newItem, ...items] |
| Insert at index | [...items.slice(0, i), x, ...items.slice(i)] |
| Remove by value | items.filter((i) => i !== value) |
| Remove by index | items.filter((_, j) => j !== i) |
| Update by index | items.map((v, j) => j === i ? newV : v) |
| Update by predicate | items.map((i) => i.id === id ? newI : i) |
| Sort | items.toSorted() |
| Reverse | items.toReversed() |
ES2023 Methods
| Method | Replaces |
|---|---|
toSorted | sort |
toReversed | reverse |
toSpliced | splice |
with | index assignment |
Arrays of Objects
| Operation | Pattern |
|---|---|
| Add | [...items, newItem] |
| Remove | items.filter((i) => i.id !== id) |
| Update one | items.map((i) => i.id === id ? { ...i, ...c } : i) |
| Toggle | items.map((i) => i.id === id ? { ...i, done: !i.done } : i) |
Best Practices
✅ Do This:
// Add with the spread
setItems((items) => [...items, newItem]);
// Remove with filter
setItems((items) => items.filter((item) => item.id !== id));
// Update with map
setItems((items) =>
items.map((item) => item.id === id ? { ...item, done: true } : item)
);
// Sort with toSorted
setItems((items) => items.toSorted());
// Use the functional form
setItems((items) => [...items, newItem]);
❌ Don’t Do This:
// Mutate with push
items.push(newItem); // ❌
setItems(items);
// Mutate with splice
items.splice(0, 1); // ❌
setItems(items);
// Sort in place
setItems(items.sort()); // ❌
// Assign by index
items[0] = newValue; // ❌
setItems(items);
// Forget the new object for a changed item
setItems((items) =>
items.map((item) => item.id === id ? (item.done = true, item) : item)
); // ❌ mutation
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| UI does not update | Mutated the array | Use the non-mutating pattern |
| Sort does not trigger re-render | sort mutates | Use toSorted or [...arr].sort() |
| Changed item does not update | Mutated the object | Create a new object with the spread |
| Unchanged items re-render | Created new objects for all items | Only create a new object for the changed item |
| Array order wrong after removal | Used splice | Use filter |
| Performance issue | New array for every change | Expected; keep arrays small |
Real-World Examples
1. Add Todo
setTodos((todos) => [...todos, { id: Date.now(), text: input }]);
2. Remove Todo
setTodos((todos) => todos.filter((t) => t.id !== id));
3. Toggle Todo
setTodos((todos) =>
todos.map((t) => t.id === id ? { ...t, done: !t.done } : t)
);
4. Prepend
setItems((items) => [newItem, ...items]);
5. Insert at Index
setItems((items) => [
...items.slice(0, index),
newItem,
...items.slice(index),
]);
6. Sort by Name
setItems((items) => items.toSorted((a, b) => a.name.localeCompare(b.name)));
7. Reverse
setItems((items) => items.toReversed());
8. Remove at Index
setItems((items) => items.toSpliced(index, 1));
9. Clear All
setItems([]);
10. Replace an Item
setItems((items) =>
items.map((item) => item.id === id ? replacement : item)
);
Visual
Mutation vs Replacement
┌──────────────────────────────────────────────────────────────┐
│ MUTATION: │
│ items.push(4); │
│ ┌───────────────┐ │
│ │ [1, 2, 3, 4] │ ← same array, same reference │
│ └───────────────┘ │
│ setItems(items) → same reference → no re-render │
│ │
│ REPLACEMENT: │
│ setItems([...items, 4]); │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ [1, 2, 3] │ │ [1, 2, 3, 4] │ ← new array │
│ └───────────────┘ └───────────────┘ new reference │
│ React sees a new reference → re-render │
└──────────────────────────────────────────────────────────────┘
Mutating vs Non-Mutating Methods
┌──────────────────────────────────────────────────────────────┐
│ MUTATING (avoid): │
│ push, pop, shift, unshift, splice, sort, reverse, arr[i]=x │
│ │
│ NON-MUTATING (use): │
│ concat, slice, map, filter, spread, toSorted, toReversed, │
│ toSpliced, with │
└──────────────────────────────────────────────────────────────┘
Array Operations
┌──────────────────────────────────────────────────────────────┐
│ ADD: [...items, newItem] │
│ REMOVE: items.filter((i) => i.id !== id) │
│ UPDATE: items.map((i) => i.id === id ? newI : i) │
│ SORT: items.toSorted() │
│ REVERSE: items.toReversed() │
│ INSERT: items.toSpliced(index, 0, newItem) │
└──────────────────────────────────────────────────────────────┘
Arrays of Objects
┌──────────────────────────────────────────────────────────────┐
│ items: [ │
│ { id: 1, done: false }, ← new object created │
│ { id: 2, done: false }, ← same reference │
│ { id: 3, done: false }, ← same reference │
│ ] │
│ │
│ setItems((items) => │
│ items.map((item) => │
│ item.id === 1 ? { ...item, done: true } : item │
│ ) │
│ ); │
│ │
│ The array is new. The changed item is new. The others │
│ keep their references, so memoized components skip them. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Rule | Never mutate array state |
| Add to end | [...items, newItem] |
| Add to start | [newItem, ...items] |
| Insert at index | [...slice(0,i), x, ...slice(i)] or toSpliced |
| Remove | items.filter(...) |
| Update | items.map(...) |
| Sort | items.toSorted() |
| Reverse | items.toReversed() |
| Mutating methods | push, pop, splice, sort, reverse |
| Non-mutating methods | map, filter, slice, concat, spread |
| Functional form | setItems((items) => [...items, newItem]) |
Key takeaways:
- Never mutate an array in state. The mutating methods —
push,pop,splice,sort,reverse, and direct index assignment — change the array’s contents but not its reference. React compares references, so the update is not detected. - Add with the spread.
[...items, newItem]appends,[newItem, ...items]prepends, and a slice-based pattern inserts at an index. The ES2023toSpliceddoes the insertion in one call. - Remove with
filter. The predicate selects the elements to keep. The result is a new array without the removed items. - Update with
map. The callback returns the new value for the changed element and the original value for the others. The result is a new array with the same length. - Sort and reverse with the ES2023 methods.
toSortedandtoReversedreturn new arrays. The oldersortandreversemutate and must not be used on state directly. - Arrays of objects require a new object for the changed item. The
mapcreates a new array, and the spread creates a new object for the item that changes. The unchanged items keep their references. - Use the functional form of the setter.
setItems(items => [...items, newItem])receives the latest array. The direct form captures the array from the render, which may be stale.
Remember: Arrays in React state are immutable. Every change produces a new array, and the new reference is what tells React to re-render. The mutating methods change the array in place and are the most common cause of “the UI does not update” bugs. The non-mutating methods — spread, map, filter, slice, concat, and the ES2023 toSorted, toReversed, and toSpliced — are the tools. For arrays of objects, the changed item also needs a new object, which keeps the unchanged items’ references stable and lets memoized components skip them. The patterns are consistent: add with spread, remove with filter, update with map, and sort or reverse with the ES2023 methods. Master these, and array state becomes predictable.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!