Оновлення об'єктів у стані

У стані може зберігатися будь-який тип значення JavaScript, включаючи об’єкти. Але вам не варто просто змінювати об’єкти, які ви зберігаєте в стані React. Натомість, коли ви хочете оновити об’єкт, вам потрібно створити новий (або зробити копію того, що існує), а потім задати стан, щоб використати цю копію.

Ви вивчите

  • Як правильно оновити об’єкт у стані React
  • Як оновити вкладений об’єкт без того, щоб його змінювати
  • Що таке незмінність та як її не порушити
  • Як зробити копіювання об’єкта менш монотонним за допомогою Immer

Що таке мутація?

Ви можете зберігати будь-який тип значення JavaScript у стані.

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

Поки що ви працювали з числами, рядками та булевими значеннями. Ці типи значень JavaScript є “незмінними”, тобто доступними тільки для читання. Ви можете збудити (trigger) повторний рендер, щоб замінити значення:

setX(5);

Стан x змінився з 0 на 5, але число 0 не змінилося. У 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>
  );
}

Занурення

Локальна мутація — це нормально

У такому коді, як цей, є помилка, оскільки він змінює об’єкт, що вже існує у стані:

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: 'Барбара',
    lastName: 'Гепворт',
    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>
        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: 'Барбара',
    lastName: 'Гепворт',
    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>
        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: 'Барбара',
    lastName: 'Гепворт',
    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>
        Email:
        <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: 'Нікі де Сен Фаль',
artwork: {
title: 'Блакитна Нана',
city: 'Гамбург',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});

Якщо б ви захотіли оновити person.artwork.city, нескладно зрозуміти, як це зробити за допомогою мутації:

person.artwork.city = 'Нью-Делі';

Але в React вам варто вважати стан незмінним! Щоб змінити city, вам спочатку треба було б створити новий об’єкт artwork (заздалегідь заповнений даними з попереднього), а потім створити новий об’єкт person, який вказує на новий artwork:

const nextArtwork = { ...person.artwork, city: 'Нью-Делі' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

Або записати як одиничний виклик функції:

setPerson({
...person, // Скопіювати інші поля
artwork: { // але замінити artwork
...person.artwork, // таким самим витвором мистецтва
city: 'Нью-Делі' // але в Нью-Делі!
}
});

Виглядає дещо багатослівним, але добре працює в багатьох випадках:

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    name: 'Нікі де Сен Фаль',
    artwork: {
      title: 'Блакитна Нана',
      city: 'Гамбург',
      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>
        <br />
        Автор: {person.name}
        <br />
        (місце розташування: {person.artwork.city})
      </p>
      <img 
        src={person.artwork.image} 
        alt={person.artwork.title}
      />
    </>
  );
}

Занурення

Об’єкти насправді не зовсім вкладені

У коді такий об’єкт, як цей, здається “вкладеним”:

let obj = {
name: 'Нікі де Сен Фаль',
artwork: {
title: 'Блакитна Нана',
city: 'Гамбург',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};

Однак думка про те, що об’єкти поводять себе так, ніби можуть бути “вкладеними”, є неточною. Під час виконання коду немає такого поняття як “вкладений” об’єкт. Насправді це два різні об’єкти:

let obj1 = {
title: 'Блакитна Нана',
city: 'Гамбург',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Нікі де Сен Фаль',
artwork: obj1
};

Об’єкт obj1 не є “всередині” об’єкта obj2. Наприклад, obj3 може “вказувати” на obj1 також:

let obj1 = {
title: 'Блакитна Нана',
city: 'Гамбург',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Нікі де Сен Фаль',
artwork: obj1
};

let obj3 = {
name: 'Повторюха',
artwork: obj1
};

Якби ви змінили властивість obj3.artwork.city, вона б вплинула і на obj2.artwork.city, і на obj1.city. Це відбувається, оскільки obj3.artwork, obj2.artwork, та obj1 є одним і тим самим об’єктом. Це складно зрозуміти, якщо ви сприймаєте об’єкти як “вкладені”. Натомість вони є окремими об’єктами, які “вказують” один на одного за допомогою властивостей.

Пишіть лаконічну логіку оновлення за допомогою Immer

Якщо ваш стан глибоко вкладений, ви, можливо, захочете звернути увагу на те, щоб його реконструювати. Але якщо ви не хочете змінювати структуру вашого стану, можете віддати перевагу спрощенню над вкладеним розгортанням. Immer — це популярна бібліотека, яка дозволяє вам писати код, використовуючи зручний синтаксис з мутаціями, що робить копії за вас. За допомогою Immer написаний вами код виглядає так, ніби ви “порушуєте правила” і змінюєте об’єкт:

updatePerson(draft => {
draft.artwork.city = 'Лагос';
});

Але на відміну від звичайної мутації, він не переписує минулий стан!

Занурення

Як Immer працює?

Чернетка draft, яку надає Immer, — це спеціальний тип об’єкта, названий Proxy, який “записує” те, що ви робите з ним. Ось чому ви можете вільно змінювати його стільки разів, скільки вам потрібно! Під капотом Immer знаходить, які частини draft були змінені, і створює абсолютно новий об’єкт, що містить ваші редагування.

Щоб спробувати Immer:

  1. Запустіть npm install use-immer, щоб додати Immer як залежність
  2. Потім замініть import { useState } from 'react' на import { useImmer } from 'use-immer'

Ось вище зазначений приклад, перетворений за допомогою Immer:

{
  "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 — незамінний помічник у тому, щоб тримати обробники подій лаконічними, особливо коли вкладення є присутнім у вашому стані та копіювання об’єктів призводить до монотонного коду.

Занурення

Існує декілька причин:

  • Налагодження: Якщо ви використовуєте console.log і ваш стан не має мутацій, попередні логи не будуть замінені недавніми змінами стану. Тому ви чітко можете побачити, як стан змінився між рендерами.
  • Оптимізація: Загальні стратегії оптимізації в React полягають в пропущенні роботи, якщо попередні пропси або стан такі ж самі, як і наступні. Якщо ви ніколи не змінюєте стан, перевірка на зміни в ньому є дуже швидкою. Якщо prevObj === obj (попередній об’єкт дорівнює теперішньому), ви можете бути впевненими, що нічого не змінилося всередині нього.
  • Нові функції: Нові функції в React, що ми створюємо, залежать від стану, який ми вважаємо снепшотом. Якщо ви змінюєте минулі версії стану, це може перешкоджати вам використовувати нові функції.
  • Зміни вимог: Деякі функції додатка, такі як Скасувати/Повторити, відображення історії змін або дозвіл користувачу скинути форму до попередніх значень, легше втілити в життя, коли немає мутацій. Це можливо, бо ви можете зберігати попередні копії стану в пам’яті і використовувати їх знову, коли це доречно. Якщо на початку ваш підхід полягає в мутаціях, то потім подібні функції може бути складно додати.
  • Легше втілення: Оскільки React не спирається на мутації, йому не треба робити нічого особливого з вашими об’єктами. Йому не треба викрадати їхні властивості, завжди загортати їх в Proxy об’єкти або виконувати іншу роботу під час ініціалізації, як це роблять багато “реактивних” фреймворків та бібліотек. Це також причина того, чому React дозволяє вам вкласти в стан будь-який об’єкт незалежно від його розміру, а також без додаткових пасток, пов’язаних з продуктивністю або правильністю.

На практиці ви часто можете “втекти” за допомогою мутацій стану в React, але ми наполегливо рекомендуємо не змінювати його для того, щоб ви могли використовувати нові функції в React, розробленими, враховуючи цей підхід. Майбутні співробітники будуть вдячні та, можливо, навіть майбутній ви скажете собі дякую!

Підсумок

  • Вважайте весь стан у React незмінним.
  • Коли ви зберігаєте об’єкти в стані, їх змінення не збудить рендери й перемінить стан у попередніх “снепшотах” рендеру.
  • Замість того, щоб змінювати об’єкт, створіть його нову версію та збудіть повторний рендер, задаючи йому стан.
  • Ви можете використати синтаксис розгортання об’єкта {...obj, something: 'newValue'}, щоб створити копії об’єктів.
  • Синтаксис розгортання є поверхневим: він копіює тільки на одному рівні.
  • Щоб оновити вкладений об’єкт, вам потрібно створити копії всього аж до того місця, що ви оновлюєте.
  • Щоб зменшити монотонне копіювання коду, використайте Immer.

Завдання 1 із 3:
Виправіть неправильне оновлення стану

Ця форма має декілька дефектів. Натисніть на кнопку, що збільшує рахунок, кілька разів. Зверніть увагу на те, що він не збільшується. Потім змініть ім’я — і ви побачите, що рахунок раптом “наздогнав” ваші зміни. Зрештою змініть прізвище — і ви побачите, що рахунок повністю зник.

Ваше завдання полягає в тому, щоб виправити всі ці дефекти. Виправляючи їх, поясніть причину кожного окремо.

import { useState } from 'react';

export default function Scoreboard() {
  const [player, setPlayer] = useState({
    firstName: 'Раняна',
    lastName: 'Шеттар',
    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>
    </>
  );
}