Обновление объектов в состоянии

Состояние может содержать в себе любые JavaScript-значения, включая объекты. Значения объектов, которые находятся в состоянии, нельзя изменять напрямую. Вместо этого, если вы хотите обновить состояние, вам необходимо создать новый объект или копию текущего объекта, а затем установить состоянию этот объект.

You will learn

  • Как правильно в React обновлять объект в состоянии
  • Как обновить вложенный объект без мутации
  • Что такое иммутабельность, как её не нарушить
  • Как упростить копирование объектов с Immer

Что такое мутация?

Вы можете хранить любое JavaScript-значение в состоянии.

const [x, setX] = useState(0);

До сих пор вы работали с числами, строками и логическими значениями. Эти виды значений в JavaScript являются “иммутабельными” (еще один вариант определения — неизменяемыми) — это значит, что они не изменяются и доступны “только для чтения”. Вы можете запустить повторный рендер для замены значения:

setX(5);

Значение состояния x изменилось с 0 на 5, но число 0 само по себе не изменилось. Невозможно проводить изменения со встроенными (built-in) примитивами, такими как числа, строки и логические значения в JavaScript.

Рассмотрим объект в состоянии:

const [position, setPosition] = useState({ x: 0, y: 0 });

Технически возможно изменить содержимое самого объекта. Это называется мутацией:

position.x = 5;

На самом деле, объекты в состоянии в React мутабельны (изменяемы), но вы должны относиться к ним так, будто они иммутабельны, как и числа, строки, логические значения. Вместо того, чтобы изменять объекты напрямую, вы должны заменять их.

Рассматривайте состояние как доступное “только для чтения”

Иными словами, вам необходимо рассматривать любой объект JavaScript, который находится в состоянии, доступным только для чтения.

В примере ниже состояние хранит в себе объект — текущее положение курсора. Красная точка должна двигаться, когда вы касаетесь или перемещаете курсор над областью предварительного просмотра. Точка в данном примере остаётся в своей исходной позиции:

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>
  );
}

Deep Dive

Локальная мутация допустима

Код ниже является проблемным, потому что изменяет существующий объект состояния:

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>
        Имя:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Фамилия:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Почта:
        <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>
        Имя:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Фамилия:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Почта:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

Помните, что ... оператор расширения является “поверхностным” — он копирует элементы только на один уровень вглубь. Это свойство делает его быстрым, но это также означает, что если вы хотите обновить вложенное свойство, вам придётся использовать его более одного раза.

Deep Dive

Использование одного обработчика событий для нескольких полей

Также вы можете использовать скобки [ и ] внутри определения вашего объекта, чтобы указывать свойство с динамическим именем. Ниже представлен тот же самый пример, но с одним обработчиком событий вместо трёх разных:

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 в <input> DOM-элемента.

Обновление вложенного объекта

Рассмотрим структуру вложенных объектов, подобную этой:

const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});

Если вы хотите обновить person.artwork.city, то понятно как это сделать с помощью мутации:

person.artwork.city = 'New Delhi';

Но в React состояние иммутабельное! Чтобы изменить city, вам сначала нужно создать новый объект artwork (предварительно заполненный данными из предыдущего artwork), а затем создать новый объект person, который указывает на новый artwork:

const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

Или записанный как вызов одной функции:

setPerson({
...person, // копируем поля
artwork: { // но заменяем artwork
...person.artwork, // с тем же значением
city: 'New Delhi' // но с 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://i.imgur.com/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>
        Автор:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Название:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        Город:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Изображение:
        <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}
      />
    </>
  );
}

Deep Dive

Объекты на самом деле не вложены

Объект в примере ниже выглядит “вложенным”:

let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};

Однако, “вложенность” — это неточный способ думать о том, как ведут себя объекты. Когда код выполняется, нет такого понятия, как “вложенный” объект. Вы действительно смотрите на два разных объекта:

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

Объект obj1 не находится внутри obj2. Например, obj3 также мог “указывать” на obj1:

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/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 не перезаписывает предыдущее состояние напрямую!

Deep Dive

Как работает Immer?

Черновик, предоставленный Immer, является особенным типом объекта, который называется прокси, он “записывает” то, что вы с ним делаете. Вот почему вы можете мутировать его столько раз, сколько захотите! Под капотом Immer выясняет, какие части черновика были изменены, и создаёт новый объект с наличием этих изменений.

Попробуйте Immer:

  1. Запустите в терминале npm install use-immer, чтобы добавить Immer как зависимость
  2. Далее замените import { useState } from 'react' на import { useImmer } from 'use-immer'

Ниже представлен пример с использованием Immer:

import { useImmer } from 'use-immer';

export default function Form() {
  const [person, updatePerson] = useImmer({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    updatePerson(draft => {
      draft.name = e.target.value;
    });
  }

  function handleTitleChange(e) {
    updatePerson(draft => {
      draft.artwork.title = e.target.value;
    });
  }

  function handleCityChange(e) {
    updatePerson(draft => {
      draft.artwork.city = e.target.value;
    });
  }

  function handleImageChange(e) {
    updatePerson(draft => {
      draft.artwork.image = e.target.value;
    });
  }

  return (
    <>
      <label>
        Автор:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Название:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        Город:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Изображение:
        <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}
      />
    </>
  );
}

Обратите внимание на лакончиный код обработчиков событий. Вы можете смешивать и сочетать useState и useImmer в отдельном компоненте так, как считаете нужным. Immer — отличный способ сделать обработчики более краткими, особенно, если в вашем состоянии есть вложенность, а копирование объектов приводит к повторяющемуся коду.

Deep Dive

Существует ряд причин:

  • Отладка: если вы используете console.log и не мутируете состояние, ваши прошлые логи не будут затёрты более поздними изменениями состояния. Вы можете явно проследить, как состояние менялось между рендерами.
  • Oптимизация: общие стратегии оптимизации работы в React основаны на пропуске работы, если прошлые пропсы и/или состояние не изменились. Можно быстро проверить были ли какие-то изменения и мутировалось ли состояние. Если выполняется равенство prevObj === obj, вы можете быть уверены, что действительно ничего не изменилось.
  • Новая функциональность: разработчики React, когда создают новую функциональность, полагаются на то, что состояние рассматривается как снимок. Если вы мутируете прошлые версии состояния, это может помешать вам использовать новую функциональность.
  • Изменения требований: некоторые функции приложения, например, такие как реализация Undo/Redo, отображение истории изменений или предоставление пользователю возможности сбросить форму до более ранних значений, проще сделать, когда ничего не изменяется. Это связано с тем, что вы можете хранить прошлые копии состояния в памяти и повторно использовать их при необходимости. Если вы будете использовать подход мутирования, впоследствии будет сложно добавить такие функции.
  • Более простая реализация: поскольку React не полагается на мутацию, ему не нужно делать ничего особенного с вашими объектами: не нужно захватывать их свойства, всегда оборачивать их в прокси или выполнять другую работу при инициализации, как это делают многие «реактивные» решения. По этой же причине React позволяет помещать в состояние любой объект, независимо от его размера, без дополнительных проблем с производительностью или корректностью.

На практике вы часто можете “уйти” от мутации состояния в React, но мы настоятельно рекомендуем вам не делать этого, чтобы вы могли использовать новые функции React, разработанные с учётом этого подхода.

Recap

  • Рассматривайте все состояния в React как иммутабельные (неизменяемые).
  • Когда вы храните объекты в состоянии, их прямое изменение не вызовет повторный рендер и изменит состояние в предыдущих “снимках” рендера.
  • Вместо этого создайте новую версию объекта и активируйте повторный рендер, установив для него состояние.
  • Вы можете использовать оператор расширения, чтобы создать новый объект на основе копии старого. Например, {...obj, something: 'newValue'}.
  • Синтаксис расширения “поверхностный”: копирование объекта происходит только на одном уровне в глубину.
  • Чтобы обновить вложенный объект, вам нужно создать копии на всем пути от того места, которое вы обновляете.
  • Для написания лаконичного кода копирования сложного объекта используйте Immer.

Challenge 1 of 3:
Исправить некорректные обновления состояния

Форма имеет несколько ошибок. Нажмите кнопку, которая увеличивает счёт. Обратите внимание, он не увеличивается визуально. Затем отредактируйте имя и обратите внимание, что счёт внезапно “догнал” ваши изменения. Наконец, отредактируйте фамилию и обратите внимание, что счёт полностью исчез.

Необходимо исправить эти ошибки. Когда вы завершите исправления, объясните, почему происходили ошибки.

import { useState } from 'react';

export default function Scoreboard() {
  const [player, setPlayer] = useState({
    firstName: 'Ranjani',
    lastName: 'Shettar',
    score: 10,
  });

  function handlePlusClick() {
    player.score++;
  }

  function handleFirstNameChange(e) {
    setPlayer({
      ...player,
      firstName: e.target.value,
    });
  }

  function handleLastNameChange(e) {
    setPlayer({
      lastName: e.target.value
    });
  }

  return (
    <>
      <label>
        Счёт: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        Имя:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Фамилия:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}