Menu

Обновление массивов и объектов в состоянии React без мутаций

Чтобы изменить массив или объект в состоянии React, создайте новый и передайте его в сеттер. Как добавлять, удалять, заменять, вставлять и сортировать элементы, обновлять вложенные объекты и почему мутация состояния оставляет экран без изменений.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

В 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, splicefilter, slice
Заменитьarr[i] = x, splicemap, with(i, x)
Вставитьsplice[...arr.slice(0, i), x, ...arr.slice(i)], toSpliced
Сортировать, развернутьsort, reversetoSorted, 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 каждый раз копирует всё; писать проще, но на большом состоянии медленнее, и он ломает равенство ссылок для неизменившихся частей.

Иллюстрация языков программирования Coddy

Учитесь программировать с Coddy

НАЧАТЬ