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

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

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

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

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

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

Досі ви працювали з числами, рядками та булеами. Ці типи значень JavaScript є "незмінними", тобто незмінними або "доступними лише для читання". Ви можете викликати повторний рендеринг, щоб замінити значення:

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>
  );
}
body { margin: 0; padding: 0; height: 250px; }

Проблема у цьому фрагменті коду.

onPointerMove={e => {
  position.x = e.clientX;
  position.y = e.clientY;
}}

Цей код модифікує об'єкт, присвоєний позиції з попереднього рендерингу. Але без використання функції встановлення стану, React не знає, що об'єкт змінився. Тому React нічого не робить у відповідь. Це все одно, що намагатися змінити порядок після того, як ви вже з'їли страву. Хоча мутація стану може працювати в деяких випадках, ми не рекомендуємо її використовувати. Значення стану, до якого ви маєте доступ під час рендерингу, слід розглядати як доступне лише для читання.

Щоб фактично запустити повторний рендеринг у цьому випадку, створіть новий об'єкт і передайте його функції встановлення стану:

onPointerMove={e => {
  setPosition({
    x: e.clientX,
    y: e.clientY
  });
}}

За допомогою setPosition ви повідомляєте React:

  • Замініть позицію цим новим об'єктом
  • І знову відрендерити цей компонент

Помітьте, як червона точка тепер слідує за вашим вказівником, коли ви торкаєтеся або наводите його на область попереднього перегляду:

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>
  );
}
body { margin: 0; padding: 0; height: 250px; }

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

Такий код є проблемою, оскільки він модифікує існуючий об'єкт у стані:

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: '[email protected]'
  });

  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>
    </>
  );
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }

Наприклад, цей рядок мутує стан з минулого рендерингу:

person.firstName = e.target.value;

Надійний спосіб отримати потрібну поведінку - створити новий об'єкт і передати його до setPerson. Але тут ви також хочете скопіювати до нього наявні дані, оскільки змінилося лише одне з полів:

setPerson({
  firstName: e.target.value, // New first name from the input
  lastName: person.lastName,
  email: person.email
});

Ви можете використовувати синтаксис ... об'єкта spread, щоб не копіювати кожну властивість окремо.

setPerson({
  ...person, // Copy the old fields
  firstName: e.target.value // But override this one
});

Тепер форма працює!

Зверніть увагу, що ви не оголосили окрему змінну стану для кожного поля введення. Для великих форм дуже зручно зберігати всі дані згрупованими в об'єкті - якщо ви правильно його оновлюєте!

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: '[email protected]'
  });

  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>
    </>
  );
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }

Зауважте, що синтаксис поширення ... є "неглибоким" - він копіює речі лише на один рівень углиб. Це робить його швидким, але також означає, що якщо ви хочете оновити вкладену властивість, вам доведеться використовувати його більше одного разу.

Використання одного обробника події для кількох полів

Ви також можете використовувати дужки [ і ] всередині визначення об'єкта, щоб вказати властивість з динамічною назвою. Ось той самий приклад, але з одним обробником події замість трьох різних:

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: '[email protected]'
  });

  function handleChange(e) {
    setPerson({
      ...person,
      [e.target.name]: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          name="firstName"
          value={person.firstName}
          onChange={handleChange}
        />
      </label>
      <label>
        Last name:
        <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>
    </>
  );
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }

Тут 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 (попередньо заповнений даними з минулого), а потім створити новий об'єкт person, який вказує на новий artwork:

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

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

setPerson({
  ...person, // Copy other fields
  artwork: { // but replace the artwork
    ...person.artwork, // with the same one
    city: 'New Delhi' // but in 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>
        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}
      />
    </>
  );
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
img { width: 200px; height: 200px; }

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

Такий об'єкт з'являється "вкладеним" у код:

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?

Чернетка draft, надана Immer, є особливим типом об'єкта, який називається Proxy, що "записує", що ви з ним робите. Ось чому ви можете вільно мутувати його як завгодно! Під капотом 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>
        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}
      />
    </>
  );
}
{
  "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"
  }
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }
img { width: 200px; height: 200px; }

Помітьте, наскільки лаконічнішими стали обробники подій. Ви можете змішувати і поєднувати useState та useImmer в одному компоненті скільки завгодно. Immer - чудовий спосіб зробити обробники оновлень лаконічними, особливо якщо у вашому стані є вкладеність, а копіювання об'єктів призводить до повторення коду.

Є кілька причин:

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

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

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

Виправлено некоректні оновлення станів

Ця форма має декілька проблем. Кілька разів натисніть кнопку, яка збільшує рахунок у кілька разів. Зверніть увагу, що він не збільшується. Потім відредагуйте ім'я і помітьте, що оцінка раптово "наздогнала" ваші зміни. Нарешті, відредагуйте прізвище і помітьте, що оцінка повністю зникла.

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

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>
        Score: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        First name:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}
label { display: block; margin-bottom: 10px; }
input { margin-left: 5px; margin-bottom: 5px; }

Ось версія з обома виправленими проблемами:

import { useState } from 'react';

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

  function handlePlusClick() {
    setPlayer({
      ...player,
      score: player.score + 1,
    });
  }

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

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

  return (
    <>
      <label>
        Score: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        First name:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}
label { display: block; }
input { margin-left: 5px; margin-bottom: 5px; }

Проблема з handlePlusClick полягала в тому, що він мутував об'єкт player. В результаті React не знав, що є причиною для повторного рендерингу, і не оновив рахунок на екрані. Ось чому, коли ви відредагували ім'я, стан оновився, що викликало повторний рендеринг, який також оновив рахунок на екрані.

Проблема з handleLastNameChange полягала у тому, що він не скопіював існуючі поля ...player у новий об'єкт. Тому після редагування прізвища рахунок втрачався.

Знайдіть та виправте мутацію

На статичному фоні є перетягуване поле. Ви можете змінити колір поля за допомогою select input.

Але є помилка. Якщо ви спочатку перемістите рамку, а потім зміните її колір, фон (який не повинен рухатися!) "перестрибне" на місце рамки. Але цього не повинно відбуватися: положення пропу Background має значення initialPosition, тобто { x: 0, y: 0 }. Чому фон рухається після зміни кольору?

Знайдіть помилку і виправте її.

Якщо щось несподівано змінилося, то це мутація. Знайдіть мутацію у App.js і виправте її.

import { useState } from 'react';
import Background from './Background.js';
import Box from './Box.js';

const initialPosition = {
  x: 0,
  y: 0
};

export default function Canvas() {
  const [shape, setShape] = useState({
    color: 'orange',
    position: initialPosition
  });

  function handleMove(dx, dy) {
    shape.position.x += dx;
    shape.position.y += dy;
  }

  function handleColorChange(e) {
    setShape({
      ...shape,
      color: e.target.value
    });
  }

  return (
    <>
      <select
        value={shape.color}
        onChange={handleColorChange}
      >
        <option value="orange">orange</option>
        <option value="lightpink">lightpink</option>
        <option value="aliceblue">aliceblue</option>
      </select>
      <Background
        position={initialPosition}
      />
      <Box
        color={shape.color}
        position={shape.position}
        onMove={handleMove}
      >
        Drag me!
      </Box>
    </>
  );
}
import { useState } from 'react';

export default function Box({
  children,
  color,
  position,
  onMove
}) {
  const [
    lastCoordinates,
    setLastCoordinates
  ] = useState(null);

  function handlePointerDown(e) {
    e.target.setPointerCapture(e.pointerId);
    setLastCoordinates({
      x: e.clientX,
      y: e.clientY,
    });
  }

  function handlePointerMove(e) {
    if (lastCoordinates) {
      setLastCoordinates({
        x: e.clientX,
        y: e.clientY,
      });
      const dx = e.clientX - lastCoordinates.x;
      const dy = e.clientY - lastCoordinates.y;
      onMove(dx, dy);
    }
  }

  function handlePointerUp(e) {
    setLastCoordinates(null);
  }

  return (
    <div
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      style={{
        width: 100,
        height: 100,
        cursor: 'grab',
        backgroundColor: color,
        position: 'absolute',
        border: '1px solid black',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        transform: `translate(
          ${position.x}px,
          ${position.y}px
        )`,
      }}
    >{children}</div>
  );
}
export default function Background({
  position
}) {
  return (
    <div style={{
      position: 'absolute',
      transform: `translate(
        ${position.x}px,
        ${position.y}px
      )`,
      width: 250,
      height: 250,
      backgroundColor: 'rgba(200, 200, 0, 0.2)',
    }} />
  );
};
body { height: 280px; }
select { margin-bottom: 10px; }

Проблема була у мутації всередині handleMove. Вона мутувала shape.position, але це той самий об'єкт, на який вказує initialPosition. Ось чому рухається і фігура, і фон. (Це мутація, тому зміна не відображається на екрані, доки не відбудеться пов'язане з нею оновлення - зміна кольору - і не відбудеться повторна візуалізація).

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

import { useState } from 'react';
import Background from './Background.js';
import Box from './Box.js';

const initialPosition = {
  x: 0,
  y: 0
};

export default function Canvas() {
  const [shape, setShape] = useState({
    color: 'orange',
    position: initialPosition
  });

  function handleMove(dx, dy) {
    setShape({
      ...shape,
      position: {
        x: shape.position.x + dx,
        y: shape.position.y + dy,
      }
    });
  }

  function handleColorChange(e) {
    setShape({
      ...shape,
      color: e.target.value
    });
  }

  return (
    <>
      <select
        value={shape.color}
        onChange={handleColorChange}
      >
        <option value="orange">orange</option>
        <option value="lightpink">lightpink</option>
        <option value="aliceblue">aliceblue</option>
      </select>
      <Background
        position={initialPosition}
      />
      <Box
        color={shape.color}
        position={shape.position}
        onMove={handleMove}
      >
        Drag me!
      </Box>
    </>
  );
}
import { useState } from 'react';

export default function Box({
  children,
  color,
  position,
  onMove
}) {
  const [
    lastCoordinates,
    setLastCoordinates
  ] = useState(null);

  function handlePointerDown(e) {
    e.target.setPointerCapture(e.pointerId);
    setLastCoordinates({
      x: e.clientX,
      y: e.clientY,
    });
  }

  function handlePointerMove(e) {
    if (lastCoordinates) {
      setLastCoordinates({
        x: e.clientX,
        y: e.clientY,
      });
      const dx = e.clientX - lastCoordinates.x;
      const dy = e.clientY - lastCoordinates.y;
      onMove(dx, dy);
    }
  }

  function handlePointerUp(e) {
    setLastCoordinates(null);
  }

  return (
    <div
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      style={{
        width: 100,
        height: 100,
        cursor: 'grab',
        backgroundColor: color,
        position: 'absolute',
        border: '1px solid black',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        transform: `translate(
          ${position.x}px,
          ${position.y}px
        )`,
      }}
    >{children}</div>
  );
}
export default function Background({
  position
}) {
  return (
    <div style={{
      position: 'absolute',
      transform: `translate(
        ${position.x}px,
        ${position.y}px
      )`,
      width: 250,
      height: 250,
      backgroundColor: 'rgba(200, 200, 0, 0.2)',
    }} />
  );
};
body { height: 280px; }
select { margin-bottom: 10px; }

Оновлення об'єкта за допомогою Immer

Це той самий приклад, що й у попередньому завданні. Цього разу виправте мутацію за допомогою Immer. Для вашої зручності useImmer вже імпортовано, тому вам потрібно змінити змінну стану shape, щоб використовувати її.

import { useState } from 'react';
import { useImmer } from 'use-immer';
import Background from './Background.js';
import Box from './Box.js';

const initialPosition = {
  x: 0,
  y: 0
};

export default function Canvas() {
  const [shape, setShape] = useState({
    color: 'orange',
    position: initialPosition
  });

  function handleMove(dx, dy) {
    shape.position.x += dx;
    shape.position.y += dy;
  }

  function handleColorChange(e) {
    setShape({
      ...shape,
      color: e.target.value
    });
  }

  return (
    <>
      <select
        value={shape.color}
        onChange={handleColorChange}
      >
        <option value="orange">orange</option>
        <option value="lightpink">lightpink</option>
        <option value="aliceblue">aliceblue</option>
      </select>
      <Background
        position={initialPosition}
      />
      <Box
        color={shape.color}
        position={shape.position}
        onMove={handleMove}
      >
        Drag me!
      </Box>
    </>
  );
}
import { useState } from 'react';

export default function Box({
  children,
  color,
  position,
  onMove
}) {
  const [
    lastCoordinates,
    setLastCoordinates
  ] = useState(null);

  function handlePointerDown(e) {
    e.target.setPointerCapture(e.pointerId);
    setLastCoordinates({
      x: e.clientX,
      y: e.clientY,
    });
  }

  function handlePointerMove(e) {
    if (lastCoordinates) {
      setLastCoordinates({
        x: e.clientX,
        y: e.clientY,
      });
      const dx = e.clientX - lastCoordinates.x;
      const dy = e.clientY - lastCoordinates.y;
      onMove(dx, dy);
    }
  }

  function handlePointerUp(e) {
    setLastCoordinates(null);
  }

  return (
    <div
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      style={{
        width: 100,
        height: 100,
        cursor: 'grab',
        backgroundColor: color,
        position: 'absolute',
        border: '1px solid black',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        transform: `translate(
          ${position.x}px,
          ${position.y}px
        )`,
      }}
    >{children}</div>
  );
}
export default function Background({
  position
}) {
  return (
    <div style={{
      position: 'absolute',
      transform: `translate(
        ${position.x}px,
        ${position.y}px
      )`,
      width: 250,
      height: 250,
      backgroundColor: 'rgba(200, 200, 0, 0.2)',
    }} />
  );
};
body { height: 280px; }
select { margin-bottom: 10px; }
{
  "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"
  }
}

Це розв'язок, переписаний за допомогою Immer. Зверніть увагу, що обробники подій написані в мутуючій манері, але помилка не виникає. Це тому, що за лаштунками Immer ніколи не мутує існуючі об'єкти.

import { useImmer } from 'use-immer';
import Background from './Background.js';
import Box from './Box.js';

const initialPosition = {
  x: 0,
  y: 0
};

export default function Canvas() {
  const [shape, updateShape] = useImmer({
    color: 'orange',
    position: initialPosition
  });

  function handleMove(dx, dy) {
    updateShape(draft => {
      draft.position.x += dx;
      draft.position.y += dy;
    });
  }

  function handleColorChange(e) {
    updateShape(draft => {
      draft.color = e.target.value;
    });
  }

  return (
    <>
      <select
        value={shape.color}
        onChange={handleColorChange}
      >
        <option value="orange">orange</option>
        <option value="lightpink">lightpink</option>
        <option value="aliceblue">aliceblue</option>
      </select>
      <Background
        position={initialPosition}
      />
      <Box
        color={shape.color}
        position={shape.position}
        onMove={handleMove}
      >
        Drag me!
      </Box>
    </>
  );
}
import { useState } from 'react';

export default function Box({
  children,
  color,
  position,
  onMove
}) {
  const [
    lastCoordinates,
    setLastCoordinates
  ] = useState(null);

  function handlePointerDown(e) {
    e.target.setPointerCapture(e.pointerId);
    setLastCoordinates({
      x: e.clientX,
      y: e.clientY,
    });
  }

  function handlePointerMove(e) {
    if (lastCoordinates) {
      setLastCoordinates({
        x: e.clientX,
        y: e.clientY,
      });
      const dx = e.clientX - lastCoordinates.x;
      const dy = e.clientY - lastCoordinates.y;
      onMove(dx, dy);
    }
  }

  function handlePointerUp(e) {
    setLastCoordinates(null);
  }

  return (
    <div
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      style={{
        width: 100,
        height: 100,
        cursor: 'grab',
        backgroundColor: color,
        position: 'absolute',
        border: '1px solid black',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        transform: `translate(
          ${position.x}px,
          ${position.y}px
        )`,
      }}
    >{children}</div>
  );
}
export default function Background({
  position
}) {
  return (
    <div style={{
      position: 'absolute',
      transform: `translate(
        ${position.x}px,
        ${position.y}px
      )`,
      width: 250,
      height: 250,
      backgroundColor: 'rgba(200, 200, 0, 0.2)',
    }} />
  );
};
body { height: 280px; }
select { margin-bottom: 10px; }
{
  "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"
  }
}