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

Массивы мутабельны в JavaScript, но вы должны относиться к ним как к иммутабельным, когда сохраняете их в состоянии. Также как и в случае с объектами, когда вы хотите обновить массив, хранящийся в состоянии, вам нужно создать новый (или создать копию уже существующего), и затем установить состояние, используя новый массив.

You will learn

  • Как добавлять, удалять, или изменять элементы в массиве в состоянии React
  • Как обновлять объект внутри массива
  • Как кратко копировать массив c Immer

Обновление массивов без мутаций

В JavaScript, массивы это просто другой тип объекта. Прямо как объекты вы должны относиться к массивам в состоянии React как к неизменяемым. Это также означает, что вы не должны переназначать элементы внутри массива, например как arr[0] = 'bird', и также вы не должны использовать методы, которые мутируют массив, такие как push() и pop().

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

Здесь представлена таблица распостраненных операций с массивами. Когда вы имеете дело с массивами внутри состояния React, вам стоит избегать методов из левой клоноки, и вместо них использовать методы из правой:

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

В качестве альтернативы, вы можете использовать Immer который позволит вам использовать методы из обоих колонок.

Pitfall

К сожалению, slice and 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: 'Марта Колвин Андраде' },
  { id: 1, name: 'Ламиди Олонаде Факейе'},
  { id: 2, name: 'Луиза Берлявски-Невельсон'},
];

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 чьи ID отличаются от 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: 'Марта Колвин Андраде' },
  { id: 1, name: 'Ламиди Олонаде Факейе'},
  { id: 2, name: 'Луиза Берлявски-Невельсон'},
];

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';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies' },
  { id: 1, title: 'Lunar Landscape' },
  { id: 2, title: 'Terracotta Army' },
];

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] and list[0] указывают на один и тот же объект. Так что изменяя nextList[0].seen, вы также изменяете list[0].seen. Это мутация состояния, которую вы должны избегать! Вы можете решить этот вопрос похожим путем обновление вложенных JavaScript объектов—копированием отдельных элементов, которые вы хотите изменить, вместо того что бы мутировать их. Вот каким образом:

Обновление объектов внутри массивов

Объекты на самом деле не находятся “внутри” массивов. Может показаться, что они находятся “внутри” в коде, но каждый объект в массиве это отдельное значение, на которое массив указывает. Вот почему вам нужно быть осторожным, когда изменяете внутренние поля, такие как list[0]. Список произведений искусства другого человека может указывать на тот же элемент в массиве!

Когда обновляется вложенное состояние, вам нужно создать копии с точки которую вы хотите обновить, вплоть до верхнего уровня. Давайте посмотрим как это работает.

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

import { useState } from 'react';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', 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: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', 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:

import { useState } from 'react';
import { useImmer } from 'use-immer';

let nextId = 3;
const initialList = [
  { id: 0, title: 'Big Bellies', seen: false },
  { id: 1, title: 'Lunar Landscape', seen: false },
  { id: 2, title: 'Terracotta Army', seen: true },
];

export default function BucketList() {
  const [myList, updateMyList] = useImmer(
    initialList
  );
  const [yourList, updateYourList] = useImmer(
    initialList
  );

  function handleToggleMyList(id, nextSeen) {
    updateMyList(draft => {
      const artwork = draft.find(a =>
        a.id === id
      );
      artwork.seen = nextSeen;
    });
  }

  function handleToggleYourList(artworkId, nextSeen) {
    updateYourList(draft => {
      const artwork = draft.find(a =>
        a.id === artworkId
      );
      artwork.seen = nextSeen;
    });
  }

  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, мутации типа 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>
  );
}