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

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

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

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

Синтаксис поширення також дозволяє додавати елемент до початку, розміщуючи його перед оригінальним масивом ...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>
  );
}

Вставка в масив

Іноді може знадобитися вставити елемент у певну позицію, яка не є ні на початку, ні в кінці. Для цього ви можете використовувати синтаксис поширення масиву ... разом із методом 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>
    </>
  );
}

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

Є деякі речі, які неможливо зробити лише за допомогою синтаксису поширення та методів, які не мутують, таких як 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>
    </>
  );
}

Тут ви використовуєте синтаксис поширення [...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;
}
}));

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

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

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] синтаксисом поширення для створення масивів з новими елементами.
  • Можете використовувати 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>
  );
}