В React массив или объект в состоянии обновляют, создавая новый и передавая его в сеттер. Никогда не меняйте существующее значение на месте: используйте spread (...), map, filter или slice, чтобы собрать копию с вашим изменением, а затем вызовите setItems(copy).
[...items, { ... }] это новый массив с ещё одним элементом, а filter возвращает новый массив без удалённого. Оба оставляют старый массив в покое.
Почему мутация не вызывает рендер
Когда вы вызываете сеттер, React сравнивает новое значение со старым через Object.is. Для массивов и объектов это сравнение ссылок, а не содержимого. Если изменить массив через push и передать обратно тот же массив, React увидит ту же ссылку, решит, что ничего не изменилось, и пропустит рендер.
Нажмите «push» несколько раз. Консоль показывает, что массив растёт, но новой строки о рендере нет, и текст остаётся a. Затем нажмите «spread»: происходит один рендер, и все добавленные элементы появляются разом, потому что они всё время были в массиве. Мутация не выбрасывает ошибку, она просто оставляет экран устаревшим, пока рендер не вызовет что-то другое. После этого нажмите «push» ещё раз, и на этот раз рендер может произойти: React не всегда может пропустить работу заранее, поэтому мутация иногда видна, а иногда нет. Эта непредсказуемость и есть причина не мутировать состояние.
Массивы: какие методы использовать и каких избегать
Правило простое: используйте методы, которые возвращают новый массив, и избегайте методов, которые меняют массив, на котором вызваны.
| Цель | Избегать (мутирует) | Использовать (возвращает новый массив) |
|---|---|---|
| Добавить | push, unshift | [...arr, item], [item, ...arr] |
| Удалить | pop, shift, splice | filter, slice |
| Заменить | arr[i] = x, splice | map, with(i, x) |
| Вставить | splice | [...arr.slice(0, i), x, ...arr.slice(i)], toSpliced |
| Сортировать, развернуть | sort, reverse | toSorted, toReversed или сначала скопировать |
toSorted, toReversed, toSpliced и with это более новые методы массивов (ES2023), которые поддерживаются всеми актуальными основными браузерами с 2023 года. Они делают ту же работу, что и их мутирующие родственники, но на копии.
Обновление одного элемента через map
Чтобы изменить один элемент, пройдитесь по массиву через map и верните новый объект для нужного элемента и исходный объект для всех остальных.
Часть { ...todo, done: !todo.done } важна. Частая ошибка: скопировать массив, но изменить объект внутри него:
const next = [...todos];
next[0].done = true; // next is new, but next[0] is the same object as todos[0]
setTodos(next);
Это рендерится, поэтому выглядит нормально, но старый и новый массивы теперь делят изменённый объект. Всё, что сравнивает старый элемент с новым (дочерний компонент с React.memo, эффект, зависящий от элемента), не видит разницы. Копируйте элемент, который меняете.
Вставка по индексу
Немутирующего insert нет, поэтому разрежьте массив на две части и поставьте новый элемент между половинами. toSpliced(index, 0, item) делает то же одним вызовом.
Задайте индекс 0 и вставьте снова: новая C встанет в начало. Замените выражение next на letters.toSpliced(index, 0, 'C'), и результат будет тем же.
Сортировка и разворот
sort и reverse меняют массив, на котором вызваны, хотя и возвращают его. Поэтому setItems(items.sort()) это мутация, за которой следует установка с той же ссылкой, то есть ошибка из начала этой страницы. Сортируйте копию.
[...scores].reverse() работает, потому что spread сначала создаёт копию, а reverse затем меняет только её.
Обновление объектов и вложенных объектов
Объекты подчиняются тому же правилу. Разверните старый объект в новый и переопределите изменяемые поля. Для вложенного объекта делайте spread на каждом уровне по пути к полю: один spread копирует только верхний уровень, поэтому внутренний объект остался бы старым.
Каждый скопированный уровень это новый объект; каждый уровень, которого вы не касаетесь, остаётся тем же объектом. Именно это делает spread дешёвым: если бы состояние хранило ещё и длинный список заказов, { ...user } скопировал бы ссылку на список, а не сам список.
Если объект в состоянии вложен на три или четыре уровня и вы всё время пишете длинные цепочки spread, это часто признак того, что состояние могло бы быть более плоским. Хранение элементов в словаре по id или разделение одного большого объекта на несколько вызовов useState делает каждое обновление коротким. Когда много событий меняют одну структуру, помогает и редьюсер (см. useReducer).
structuredClone или spread
structuredClone(user) делает глубокую копию одним вызовом, после чего копию можно свободно менять:
const next = structuredClone(user);
next.address.city = 'Oslo';
setUser(next);
Это правильно, но каждый раз копирует весь объект, включая неизменённые части. Это стоит времени на большом состоянии и означает, что каждый вложенный объект получает новую ссылку, поэтому мемоизированный дочерний компонент, который получает user.orders, рендерится снова, хотя заказы не изменились. Кроме того, structuredClone выбрасывает ошибку на функциях, а экземпляры классов возвращаются как обычные объекты. Spread копирует только путь, который вы меняете, поэтому его обычно и выбирают; оставьте structuredClone для редкого обновления, которое действительно переписывает большую часть объекта.
Мутации с Immer
Immer это небольшая библиотека, которая позволяет писать код, похожий на мутацию. Она даёт вам черновик, записывает, что вы меняете, и создаёт новое неизменяемое значение, в котором скопированы только изменённые части. Пакет use-immer оборачивает её в хук:
import { useImmer } from 'use-immer';
function Profile() {
const [user, updateUser] = useImmer({
name: 'Ada',
address: { city: 'London' },
});
function moveTo(city) {
updateUser((draft) => {
draft.address.city = city;
});
}
// ...
}
Immer недоступен в редакторах на этой странице, которые загружают только React. Его стоит добавить, когда состояние глубоко вложено и spread становится трудно читать. Для плоского состояния достаточно обычного spread и map, и именно их вы увидите в большей части кода на React.
Часто задаваемые вопросы
Как добавить элемент в массив в состоянии React?
Создайте новый массив, который содержит старые элементы плюс новый, и передайте его в сеттер: setItems([...items, newItem]). Не вызывайте items.push(newItem), потому что это меняет существующий массив, и React видит ту же ссылку.
Почему компонент не рендерится заново, когда я обновляю массив?
Скорее всего, вы изменили массив на месте (push, splice, sort или arr[i] = x) и передали обратно тот же массив. React сравнивает старое и новое значение через Object.is, находит тот же объект и пропускает рендер. Вместо этого передайте новый массив.
Как обновить один объект внутри массива в React?
Используйте map и возвращайте новый объект для элемента, который меняется, и исходный элемент для всех остальных: setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t)).
Как обновить вложенный объект в состоянии React?
Копируйте каждый уровень на пути к изменяемому полю: setUser({ ...user, address: { ...user.address, city: 'Oslo' } }). Один spread копирует только верхний уровень, поэтому внутренний объект остался бы общим.
Что использовать для состояния React: structuredClone или spread?
Предпочитайте spread. Он копирует только то, что меняется, и оставляет нетронутые части теми же объектами, что помогает React.memo и сравнениям. structuredClone каждый раз копирует всё; писать проще, но на большом состоянии медленнее, и он ломает равенство ссылок для неизменившихся частей.