Оновлення масивів у стані

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

You will learn

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

Оновлення масивів без мутації

У JavaScript масиви — це ще один вид об’єктів. Як і з об’єктами, потрібно розглядати масиви в стані React як доступні лише для читання. Це означає, що не слід повторно присвоювати значення елементам усередині масиву, наприклад, arr[0] = 'bird', а також використовувати методи, які мутують масив, як-от push() і pop().

Натомість кожного разу, коли хочете оновити масив, потрібно передати новий масив у функцію задання стану. Щоб зробити це, ви можете створити новий масив із вихідного у вашому стані, викликавши його немутаційні методи, як-от filter() і map(). Потім можете задати стан для нового отриманого масиву.

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

уникати (змінює масив)віддати перевагу (повертає новий масив)
додаванняpush, unshiftconcat, spread-синтаксис [...arr] (приклад)
видаленняpop, shift, splicefilter, slice (приклад)
замінаsplice, arr[i] = ... присвоєнняmap (приклад)
сортуванняreverse, sortспочатку скопіюйте масив (приклад)

Крім того, ви можете скористатися Immer, що дозволить використовувати методи з обох стовпців.

Pitfall

На жаль, slice та splice мають схожі назви, але дуже відрізняються:

  • slice дозволяє копіювати масив або його частину.
  • splice змінює масив (для вставлення або видалення елементів).

У React ви будете використовувати slice (без p!) набагато частіше, тому що не потрібно мутувати об’єкти чи масиви в стані. Оновлення об’єктів пояснює, що таке мутація та чому вона не рекомендована для стану.

Додавання до масиву

push() змінить масив, що вам не потрібно:

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Скульптори, які надихають:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>Додати</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

Натомість створіть новий масив, який містить існуючі елементи і новий елемент у кінці. Це можна зробити кількома способами, але найпростішим є використання ... — синтаксису spread для масиву:

setArtists( // Замінити стан
[ // новим масивом
...artists, // який містить усі попередні елементи
{ id: nextId++, name: name } // і новий елемент у кінці
]
);

Тепер все працює правильно:

import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  return (
    <>
      <h1>Скульптори, які надихають:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>Додати</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

Синтаксис spread також дозволяє додавати елемент до початку, розміщуючи його перед оригінальним масивом ...artists:

setArtists([
{ id: nextId++, name: name },
...artists // Покладіть попередні елементи в кінці
]);

Отже, поширення може виконувати роботу як push(), додаючи в кінець масиву, так і unshift(), додаючи до початку. Спробуйте це в пісочниці вище!

Видалення з масиву

Найпростіший спосіб видалити елемент із масиву — це відфільтрувати його. Іншими словами, створити новий масив, який не міститиме цей елемент. Для цього скористайтеся методом filter, наприклад:

import { useState } from 'react';

let initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [artists, setArtists] = useState(
    initialArtists
  );

  return (
    <>
      <h1>Скульптори, які надихають:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              Видалити
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

Натисніть кнопку “Видалити” кілька разів і подивіться на її обробник клацання.

setArtists(
artists.filter(a => a.id !== artist.id)
);

Тут artists.filter(a => a.id !== artist.id) означає “створити масив, який складається з тих artists, ідентифікатори яких відрізняються від artist.id”. Іншими словами, кнопка “Видалити” кожного скульптора відфільтровує цього скульптора з масиву, а потім запитує повторний рендер з отриманим масивом. Зауважте, що filter не змінює вихідний масив.

Перетворення масиву

Якщо потрібно змінити деякі або всі елементи масиву, можна скористатися map(), щоб створити новий масив. Функція, яку передасте map, може вирішити, що робити з кожним елементом на основі його даних або індексу (або обох).

У цьому прикладі масив містить координати двох кіл і квадрата. Коли ви натискаєте кнопку, вона пересуває лише кола вниз на 50 пікселів. Це робиться через створення нового масиву даних за допомогою map():

import { useState } from 'react';

let initialShapes = [
  { id: 0, type: 'circle', x: 50, y: 100 },
  { id: 1, type: 'square', x: 150, y: 100 },
  { id: 2, type: 'circle', x: 250, y: 100 },
];

export default function ShapeEditor() {
  const [shapes, setShapes] = useState(
    initialShapes
  );

  function handleClick() {
    const nextShapes = shapes.map(shape => {
      if (shape.type === 'square') {
        // Без змін
        return shape;
      } else {
        // Повертає нове коло нижче на 50px
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // Повторний рендер з новим масивом
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        Перемістити кола вниз!
      </button>
      {shapes.map(shape => (
        <div
          key={shape.id}
          style={{
          background: 'purple',
          position: 'absolute',
          left: shape.x,
          top: shape.y,
          borderRadius:
            shape.type === 'circle'
              ? '50%' : '',
          width: 20,
          height: 20,
        }} />
      ))}
    </>
  );
}

Заміна елементів у масиві

Особливо часто потрібно замінити один або кілька елементів у масиві. Присвоєння на кшталт arr[0] = 'bird' мутують оригінальний масив, тому натомість ви також можете скористатися map.

Щоб замінити елемент, створіть новий масив за допомогою map. У виклику map ви маєте індекс елемента другим аргументом. Використовуйте його, щоб вирішити, повертати оригінальний елемент (перший аргумент) чи щось інше:

import { useState } from 'react';

let initialCounters = [
  0, 0, 0
];

export default function CounterList() {
  const [counters, setCounters] = useState(
    initialCounters
  );

  function handleIncrementClick(index) {
    const nextCounters = counters.map((c, i) => {
      if (i === index) {
        // Збільшити лічильник біля натиснутих клавіш
        return c + 1;
      } else {
        // Решта не змінилися
        return c;
      }
    });
    setCounters(nextCounters);
  }

  return (
    <ul>
      {counters.map((counter, i) => (
        <li key={i}>
          {counter}
          <button onClick={() => {
            handleIncrementClick(i);
          }}>+1</button>
        </li>
      ))}
    </ul>
  );
}

Вставляння в масив

Іноді може знадобитися вставити елемент у певну позицію, яка не є ні на початку, ні в кінці. Для цього ви можете використовувати spread-синтаксис ... разом із методом slice(). Метод slice() дозволяє вирізати “шматочок” масиву. Щоб вставити елемент, ви створюєте масив, який розподіляє фрагмент перед точкою вставлення, потім новий елемент, а потім решту вихідного масиву.

У цьому прикладі кнопка “Вставити” завжди вставляє елемент з індексом 1:

import { useState } from 'react';

let nextId = 3;
const initialArtists = [
  { id: 0, name: 'Marta Colvin Andrade' },
  { id: 1, name: 'Lamidi Olonade Fakeye'},
  { id: 2, name: 'Louise Nevelson'},
];

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState(
    initialArtists
  );

  function handleClick() {
    const insertAt = 1; // Може бути будь-яким індексом
    const nextArtists = [
      // Елементи перед точкою вставлення:
      ...artists.slice(0, insertAt),
      // Новий елемент:
      { id: nextId++, name: name },
      // Елементи після точки вставлення:
      ...artists.slice(insertAt)
    ];
    setArtists(nextArtists);
    setName('');
  }

  return (
    <>
      <h1>Скульптори, які надихають:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        Вставити
      </button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

Внесення інших змін до масиву

Дещо неможливо зробити лише за допомогою синтаксису spread та методів, як-от map() і filter(), які не мутують масив. Наприклад, може знадобитися розмістити елементи масиву у зворотному напрямку або відсортувати їх. Методи JavaScript reverse() і sort() мутують оригінальний масив, тому не можна використовувати їх безпосередньо.

Однак можна спочатку зробити копію масиву, а потім внести в неї зміни.

Наприклад:

import { useState } from 'react';

const initialList = [
  { id: 0, title: 'Великі животи' },
  { id: 1, title: 'Місячний пейзаж' },
  { id: 2, title: 'Теракотова армія' },
];

export default function List() {
  const [list, setList] = useState(initialList);

  function handleClick() {
    const nextList = [...list];
    nextList.reverse();
    setList(nextList);
  }

  return (
    <>
      <button onClick={handleClick}>
        Змінити порядок
      </button>
      <ul>
        {list.map(artwork => (
          <li key={artwork.id}>{artwork.title}</li>
        ))}
      </ul>
    </>
  );
}

Тут ви використовуєте spread-синтаксис [...list], щоб спочатку створити копію вихідного масиву. Тепер, коли маєте копію, можете використовувати методи зміни, як-от nextList.reverse() або nextList.sort(), або навіть призначати окремі елементи за допомогою nextList[0] = "something".

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

const nextList = [...list];
nextList[0].seen = true; // Проблема: мутація list[0]
setList(nextList);

Хоча nextList і list — це два різні масиви, nextList[0] і list[0] вказують на той самий об’єкт. Отже, змінивши nextList[0].seen, ви також змінюєте list[0].seen. Це мутація стану, якої слід уникати! Цю проблему можна вирішити подібно до оновлення вкладених об’єктів JavaScript – скопіювавши окремі елементи, які потрібно змінити, а не видозмінювати їх. Ось як.

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

Об’єкти справді не розташовані “всередині” масивів. Вони можуть здаватися тими, що “всередині” коду, але кожен об’єкт у масиві є окремим значенням, на яке “вказує” масив. Ось чому потрібно бути обережним, змінюючи вкладені поля, як-от list[0]. Інші водночас можуть працювати з тим самим елементом масиву!

Під час оновлення вкладеного стану вам потрібно створити копії від точки оновлення і аж до верхнього рівня. Давайте подивимося, як це робиться.

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

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Великі животи', seen: false },
  { id: 1, title: 'Місячний пейзаж', seen: false },
  { id: 2, title: 'Теракотова армія', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    const myNextList = [...myList];
    const artwork = myNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setMyList(myNextList);
  }

  function handleToggleYourList(artworkId, nextSeen) {
    const yourNextList = [...yourList];
    const artwork = yourNextList.find(
      a => a.id === artworkId
    );
    artwork.seen = nextSeen;
    setYourList(yourNextList);
  }

  return (
    <>
      <h1>Мистецький список</h1>
      <h2>Мій список для перегляду:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Ваш список для перегляду:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

Проблема полягає в цьому коді:

const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // Проблема: мутація існуючого елементу
setMyList(myNextList);

Хоча сам масив myNextList є новим, його елементи ті самі, що і в оригінальному масиві myList. Отже, зміна artwork.seen змінює оригінальний елемент. Цей елемент також є у yourList, що спричиняє помилку. Про такі помилки важко здогадатися, але, на щастя, вони зникають, якщо ви уникаєте зміни стану.

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

setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// Створення *нового* зміненого об'єкта
return { ...artwork, seen: nextSeen };
} else {
// Без змін
return artwork;
}
}));

Тут ... — синтаксис spread об’єкта, який використовується для створення копії об’єкта.

За допомогою цього підходу жоден із існуючих елементів стану не змінюється, і помилку виправлено:

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Великі животи', seen: false },
  { id: 1, title: 'Місячний пейзаж', seen: false },
  { id: 2, title: 'Теракотова армія', seen: true },
];

export default function BucketList() {
  const [myList, setMyList] = useState(initialList);
  const [yourList, setYourList] = useState(
    initialList
  );

  function handleToggleMyList(artworkId, nextSeen) {
    setMyList(myList.map(artwork => {
      if (artwork.id === artworkId) {
        // Створення *нового* зміненого об'єкта
        return { ...artwork, seen: nextSeen };
      } else {
        // Без змін
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // Створення *нового* зміненого об'єкта
        return { ...artwork, seen: nextSeen };
      } else {
        // Без змін
        return artwork;
      }
    }));
  }

  return (
    <>
      <h1>Мистецький список</h1>
      <h2>Мій список для перегляду:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Ваш список для перегляду:</h2>
      <ItemList
        artworks={yourList}
        onToggle={handleToggleYourList} />
    </>
  );
}

function ItemList({ artworks, onToggle }) {
  return (
    <ul>
      {artworks.map(artwork => (
        <li key={artwork.id}>
          <label>
            <input
              type="checkbox"
              checked={artwork.seen}
              onChange={e => {
                onToggle(
                  artwork.id,
                  e.target.checked
                );
              }}
            />
            {artwork.title}
          </label>
        </li>
      ))}
    </ul>
  );
}

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

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

Оновлення вкладених масивів без мутації може приводити до деяких повторень. Так само, як і з об’єктами:

  • Як правило, вам не потрібно оновлювати стан більш ніж на кілька рівнів у глибину. Якщо об’єкти стану дуже глибокі, можете реструктурувати їх щоб вони були більш плоскими.
  • Якщо ви не хочете змінювати структуру стану, можете віддати перевагу 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": {}
}

Зверніть увагу, що з використанням Immer, мутація, як-от artwork.seen = nextSeen тепер задовільна:

updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});

Це тому, що ви мутуєте не вихідний стан, а спеціальну чорнетку — об’єкт draft з Immer. Аналогічно, можете застосувати такі методи мутації, як push() та pop(), до змісту draft.

Під капотом Immer завжди будує наступний стан з нуля відповідно до змін, які зроблено для draft-об’єкта. Це підтримує обробники подій дуже стислими, не мутуючи стан.

Recap

  • Можна вкласти масиви в стан, але не можна їх змінювати.
  • Замість мутування масиву створіть його нову версію та оновіть стан.
  • Можете скористатися [...arr, newItem] spread-синтаксисом для створення масивів із новими елементами.
  • Можете використовувати filter() та map() для створення нових масивів із відфільтрованими та перетвореними елементами.
  • Можете скористатися Immer для стислості коду.

Challenge 1 of 4:
Оновіть товар у кошику

Напишіть логіку в методі handleIncreaseClick так, щоб натискання ”+” збільшувало відповідне число:

import { useState } from 'react';

const initialProducts = [{
  id: 0,
  name: 'Пахлава',
  count: 1,
}, {
  id: 1,
  name: 'Сир',
  count: 5,
}, {
  id: 2,
  name: 'Спагеті',
  count: 2,
}];

export default function ShoppingCart() {
  const [
    products,
    setProducts
  ] = useState(initialProducts)

  function handleIncreaseClick(productId) {

  }

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name}
          {' '}
          (<b>{product.count}</b>)
          <button onClick={() => {
            handleIncreaseClick(product.id);
          }}>
            +
          </button>
        </li>
      ))}
    </ul>
  );
}