Обновление объектов в state
State может содержать любые значения JavaScript, включая объекты. Однако не следует напрямую изменять объекты, хранящиеся в state React. Вместо этого, когда вам нужно обновить объект, необходимо создать новый (или сделать копию существующего), а затем установить state так, чтобы он использовал эту копию.
Вы узнаете
- Как правильно обновлять объект в состоянии React
- Как обновлять вложенный объект, не изменяя его
- Что такое неизменяемость и как не нарушить её
- Как сделать копирование объектов менее рутинным с помощью Immer
Что такое мутация?
В state можно хранить любые значения JavaScript.
const [x, setX] = useState(0);До сих пор вы работали с числами, строками и булевыми значениями. Эти типы значений JavaScript являются 'неизменяемыми', то есть неизменяемыми или 'только для чтения'. Вы можете инициировать повторный рендеринг, чтобы заменить значение:
setX(5);Состояние x изменилось с 0 на 5, но само число 0 не изменилось. В JavaScript невозможно вносить какие-либо изменения во встроенные примитивные значения, такие как числа, строки и булевы значения.
Теперь рассмотрим объект в state:
const [position, setPosition] = useState( { x: 0, y: 0 });Технически можно изменить содержимое самого объекта. Это называется мутацией:
position.x = 5;Однако, хотя объекты в состоянии React технически изменяемы, вы должны относиться к ним так, как будто они неизменяемы — как к числам, булевым значениям и строкам. Вместо того чтобы изменять их, вы всегда должны заменять их.
Относитесь к состоянию как к только для чтения
Другими словами, вы должны **относиться к любому объекту JavaScript, который вы помещаете в состояние, как к только для чтения. **
В этом примере в state хранится объект, представляющий текущее положение указателя. Красная точка должна перемещаться, когда вы касаетесь области предварительного просмотра или наводите на нее курсор. Но точка остается в исходном положении:
import { useState } from 'react';export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { position.x = e.clientX; position.y = e.clientY; }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: \`translate(\${position.x}px, \${position.y}px)\`, left: -10, top: -10, width: 20, height: 20, }} /> </div> );}Проблема заключается в этом фрагменте кода.
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}Этот код изменяет объект, присвоенный position, по сравнению с предыдущим рендерингом. Но без использования функции установки состояния React не знает, что объект изменился. Поэтому React ничего не делает в ответ. Это как пытаться изменить заказ после того, как вы уже съели блюдо. Хотя изменение состояния может работать в некоторых случаях, мы не рекомендуем этого делать. Вы должны рассматривать значение состояния, к которому у вас есть доступ при рендеринге, как доступное только для чтения.
Чтобы действительно запустить повторный рендеринг в этом случае, создайте новый объект и передайте его в функцию установки состояния:
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}С помощью setPosition вы говорите React:
- Замени
positionэтим новым объектом - И снова отрисовать этот компонент
Обратите внимание, как красная точка теперь следует за вашим указателем, когда вы касаетесь области предварительного просмотра или наводите на нее курсор:
import { useState } from 'react';export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { setPosition({ x: e.clientX, y: e.clientY }); }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: \`translate(\${position.x}px, \${position.y}px)\`, left: -10, top: -10, width: 20, height: 20, }} /> </div> );}Локальная мутация — это нормально
Такой код представляет проблему, поскольку он изменяет существующий объект в состоянии:
position.x = e.clientX;
position.y = e.clientY;Но такой код абсолютно нормален, потому что вы изменяете новый объект, который только что создали:
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);Фактически, это полностью эквивалентно написанию следующего кода:
setPosition({
x: e.clientX,
y: e.clientY
});Мутация является проблемой только в том случае, если вы изменяете существующие объекты, которые уже находятся в состоянии. Изменение объекта, который вы только что создали, допустимо, поскольку ни один другой код еще не ссылается на него. Его изменение не повлияет случайно на что-то, что от него зависит. Это называется 'локальной мутацией'. Вы даже можете выполнять локальную мутацию во время рендеринга. Очень удобно и совершенно нормально!
Копирование объектов с помощью синтаксиса развертки
В предыдущем примере объект position всегда создается заново из текущей позиции курсора. Но часто вам захочется включить существующие данные в качестве части нового объекта, который вы создаете. Например, вы можете захотеть обновить только одно поле в форме, но сохранить предыдущие значения для всех остальных полей.
Эти поля ввода не работают, потому что обработчики onChange изменяют состояние:
import { useState } from 'react';export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleFirstNameChange(e) { person.firstName = e.target.value; } function handleLastNameChange(e) { person.lastName = e.target.value; } function handleEmailChange(e) { person.email = e.target.value; } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> );}Например, эта строка изменяет состояние, полученное при предыдущем рендеринге:
person.firstName = e.target.value;Надежный способ добиться желаемого поведения — создать новый объект и передать его в setPerson. Но в данном случае вам также нужно скопировать в него существующие данные, поскольку изменилось только одно из полей:
setPerson({
firstName: e.target.value, // Новое имя из ввода
lastName: person.lastName,
email: person.email
});Вы можете использовать синтаксис ... распространение объекта, чтобы не копировать каждое свойство по отдельности.
setPerson({
...person, // Скопируйте старые поля
firstName: e.target.value // Но переопределите это
});Теперь форма работает!
Обратите внимание, что вы не объявляли отдельную переменную состояния для каждого поля ввода. Для больших форм очень удобно хранить все данные, сгруппированные в одном объекте — при условии, что вы обновляете его правильно!
import { useState } from 'react';export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleFirstNameChange(e) { setPerson({ ...person, firstName: e.target.value }); } function handleLastNameChange(e) { setPerson({ ...person, lastName: e.target.value }); } function handleEmailChange(e) { setPerson({ ...person, email: e.target.value }); } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> );}Обратите внимание, что синтаксис распространения ... является 'неглубоким' — он копирует элементы только на один уровень вглубь. Это делает его быстрым, но также означает, что если вы хотите обновить вложенное свойство, вам придется использовать его более одного раза.
Использование одного обработчика событий для нескольких полей
Вы также можете использовать фигурные скобки [ и ] внутри определения объекта, чтобы указать свойство с динамическим именем. Вот тот же пример, но с одним обработчиком событий вместо трех разных:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
function handleChange(e) {
setPerson({
...person,
[e.target.name]: e.target.value
});
}
return (
<>
<label>
Имя:
<input
name="firstName"
value={person.firstName}
onChange={handleChange}
/>
</label>
<label>
Фамилия:
<input
name='lastName'
value={person.lastName}
onChange={handleChange}
/>
</label>
<label>
Электронная почта:
<input
name="email"
value={person.email}
onChange={handleChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}Здесь e.target.name относится к свойству name, присвоенному элементу DOM <input>.
Обновление вложенного объекта
Рассмотрим такую структуру вложенного объекта:
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
}
});Если вы хотите обновить person.artwork.city, то с помощью мутации это сделать просто:
person.artwork.city = 'New Delhi';Но в React состояние считается неизменяемым! Чтобы изменить city, сначала нужно создать новый объект artwork (предварительно заполненный данными из предыдущего), а затем создать новый объект person, который указывает на новый artwork:
const nextArtwork = { ...person. artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);Или, написано как один вызов функции:
setPerson({
...person, // Копируем остальные поля
artwork: { // но заменяем произведение искусства
...person.artwork, // на то же самое
city: 'New Delhi' // но в Нью-Дели!
}
});Это получается немного многословно, но в большинстве случаев работает нормально:
import { useState } from 'react';export default function Form() { const [person, setPerson] = useState({ name: 'Niki de Saint Phalle', artwork: { title: 'Blue Nana', city: 'Hamburg', image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg', } }); function handleNameChange(e) { setPerson({ ...person, name: e.target.value }); } function handleTitleChange(e) { setPerson({ ...person, artwork: { ...person.artwork, title: e.target.value } }); } function handleCityChange(e) { setPerson({ ...person, artwork: { ...person.artwork, city: e.target.value } }); } function handleImageChange(e) { setPerson({ ...person, artwork: { ...person.artwork, image: e.target.value } }); } return ( <> <label> Name: <input value={person.name} onChange={handleNameChange} /> </label> <label> Title: <input value={person.artwork.title} onChange={handleTitleChange} /> </label> <label> City: <input value={person.artwork.city} onChange={handleCityChange} /> </label> <label> Image: <input value={person.artwork.image} onChange={handleImageChange} /> </label> <p> <i>{person.artwork.title}</i> {' by '} {person.name} <br /> (located in {person.artwork.city}) </p> <img src={person.artwork.image} alt={person.artwork.title} /> </> );}Объекты на самом деле не вложены
Такой объект выглядит 'вложенным' в коде:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
}
};Однако 'вложенность' — это неточный способ представления о том, как ведут себя объекты. При выполнении кода не существует такого понятия, как 'вложенный' объект. На самом деле вы имеете дело с двумя разными объектами:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};Объект obj1 не находится 'внутри' obj2. Например, obj3 тоже может 'указывать' на obj1:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://react.dev/images/docs/scientists/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
let obj3 = {
name: 'Copycat',
artwork: obj1
};Если вы измените obj3.artwork.city, это повлияет как на obj2.artwork.city, так и на obj1.city. Это происходит потому, что obj3.artwork, obj2.artwork и obj1 — это один и тот же объект. Это сложно увидеть, если думать об объектах как о 'вложенных'. На самом деле это отдельные объекты
, 'указывающие' друг на друга с помощью свойств.
Напишите лаконичную логику обновления с помощью Immer
Если ваше состояние глубоко вложено, вам, возможно, стоит рассмотреть возможность его уплощения. Но если вы не хотите менять структуру состояния, вам, возможно, понравится быстрый способ работы с вложенными развернутыми списками. Immer — популярная библиотека, которая позволяет писать код, используя удобный, но мутирующий синтаксис, и сама заботится о создании копий. С Immer написанный вами код выглядит так, будто вы 'нарушаете правила' и мутируете объект:
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});Но в отличие от обычной мутации, это не перезаписывает предыдущее состояние!
Как работает Immer?
draft, предоставляемый Immer, — это особый тип объекта, называемый Proxy, который 'записывает' то, что вы с ним делаете. Именно поэтому вы можете изменять его сколько угодно! Внутри Immer определяет, какие части draft были изменены, и создает совершенно новый объект, содержащий ваши правки.
Чтобы попробовать Immer:
- Запустите
npm install use-immer, чтобы добавить Immer в качестве зависимости - Затем замените
import { useState } from 'react'наimport { useImmer } from 'use-immer'
Вот приведенный выше пример, преобразованный в Immer:
//package.json
{
"dependencies": {
"immer": "1.7.3",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"use-immer": "0.5.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"devDependencies": {}
}Обратите внимание, насколько лаконичнее стали обработчики событий. Вы можете комбинировать useState и useImmer в одном компоненте как угодно. Immer — отличный способ сохранить лаконичность обработчиков обновлений, особенно если в вашем состоянии есть вложенность, а копирование объектов приводит к повторяющемуся коду.
Почему в React не рекомендуется изменять состояние?
Есть несколько причин:
- Отладка: Если вы используете
console.logи не изменяете состояние, ваши прошлые логи не будут затеряны из-за более поздних изменений состояния. Таким образом, вы можете четко видеть, как состояние изменилось между рендерингами. - Оптимизация: Обычные [стратегии оптимизации](/reference/
react/memo) полагаются на пропуск работы, если предыдущие пропсы или состояние совпадают со следующими. Если вы никогда не изменяете состояние, проверить наличие изменений можно очень быстро. Если prevObj === obj, вы можете быть уверены, что внутри него ничего не изменилось.
- Новые функции: Новые функции React, которые мы разрабатываем, полагаются на то, что состояние [рассматривается как моментальный снимок.](/learn/ state-as-a-snapshot) Если вы изменяете прошлые версии состояния, это может помешать вам использовать новые функции.
- Изменения требований: Некоторые функции приложения, такие как реализация отмены/повтора действий, отображение истории изменений или предоставление пользователю возможности сбросить форму до предыдущих значений, проще реализовать, когда ничего не изменяется. Это связано с тем, что вы можете хранить прошлые копии состояния в памяти и повторно использовать их при необходимости. Если вы начнете с подхода, предполагающего изменение состояния, такие функции будет сложно добавить позже.
- Более простая реализация: Поскольку React не полагается на мутации, ему не нужно делать ничего особенного с вашими объектами. Ему не нужно перехватывать их свойства, всегда оборачивать их в прокси или выполнять другие действия при инициализации, как это делают многие 'реактивные' решения. Именно поэтому React позволяет помещать в состояние любой объект — независимо от его размера — без дополнительных проблем с производительностью или корректностью.
На практике в React часто можно 'отделаться' мутацией состояния, но мы настоятельно рекомендуем вам этого не делать, чтобы вы могли использовать новые функции React, разработанные с учетом этого подхода. Будущие участники проекта и, возможно, даже вы сами в будущем будут вам благодарны!
Вывод
- Рассматривайте все состояния в React как неизменяемые.
- Когда вы храните объекты в состоянии, их изменение не вызовет рендеринг и изменит состояние в предыдущих 'снимках' рендеринга.
- Вместо изменения объекта создайте его новую версию и вызовите повторный рендеринг, установив состояние на нее.
- Вы можете использовать синтаксис развертки объектов
{...obj, something: 'newValue'}для создания копий объектов. - Синтаксис развертки является поверхностным: он копирует только один уровень вглубь.
- Чтобы обновить вложенный объект, вам нужно создавать копии вверх по цепочке от места, которое вы обновляете.
- Чтобы сократить повторяющийся код копирования, используйте Immer.
Последовательная обработка обновлений состояния
Установка значения переменной состояния запускает очередной рендеринг. Однако иногда может потребоваться выполнить несколько операций с этим значением, прежде чем запускать следующий рендеринг. Для этого полезно понять, как React группирует обновления состояния.
Обновление массивов в state
В JavaScript массивы являются изменяемыми, но при хранении их в state следует рассматривать как неизменяемые. Как и в случае с объектами, если вы хотите обновить массив, хранящийся в state, вам нужно создать новый (или сделать копию существующего), а затем настроить state так, чтобы он использовал новый массив.