배열 State 업데이트하기

배열은 JavaScript에서는 변경이 가능하지만, state로 저장할 때에는 변경할 수 없도록 처리해야 합니다. 객체와 마찬가지로, state에 저장된 배열을 업데이트하고 싶을 때에는, 새 배열을 생성(혹은 기존 배열의 복사본을 생성)한 뒤, 이 새 배열을 state로 두어 업데이트해야 합니다.

학습 내용

  • React state에서 배열의 항목을 추가, 삭제 또는 변경하는 방법
  • 배열 내부의 객체를 업데이트하는 방법
  • Immer로 덜 반복해서 배열을 복사하는 방법

변경하지 않고 배열 업데이트하기

JavaScript에서 배열은 다른 종류의 객체입니다. 객체와 마찬가지로 React state에서 배열은 읽기 전용으로 처리해야 합니다. 즉 arr[0] = 'bird'처럼 배열 내부의 항목을 재할당해서는 안 되며 push()pop()같은 함수로 배열을 변경해서는 안됩니다.

대신 배열을 업데이트할 때마다 배열을 state 설정 함수에 전달해야 합니다. 이를 위해 state의 원본 배열을 변경시키지 않는 filter()map() 같은 함수를 사용하여 원본 배열로부터 새 배열을 만들 수 있습니다. 이후 이 새 배열들을 state에 설정합니다.

다음은 일반적인 배열 연산에 대한 참조 표입니다. React state 내에서 배열을 다룰 땐, 왼쪽 열에 있는 함수들의 사용을 피하는 대신, 오른쪽 열에 있는 함수들을 선호해야 합니다.

비선호 (배열을 변경)선호 (새 배열을 반환)
추가push, unshiftconcat, [...arr] 전개 연산자 (예시)
제거pop, shift, splicefilter, slice (예시)
교체splice, arr[i] = ... 할당map (예시)
정렬reverse, sort배열을 복사한 이후 처리 (예시)

또는 두 열의 함수를 모두 사용할 수 있도록 하는 Immer를 사용할 수 있습니다.

주의하세요!

안타깝지만, slicesplice 함수는 이름이 비슷하지만 몹시 다릅니다.

  • slice를 사용하면 배열 또는 그 일부를 복사할 수 있습니다.
  • splice는 배열을 변경합니다. (항목을 추가하거나 제거합니다.)

React에서는, state의 객체나 배열을 변경하지 않는 게 좋기 때문에 slice (p가 없습니다!)를 훨씬 더 자주 사용하게 될 것입니다. 객체 업데이트에서 변경이 무엇이고 왜 state에 권장되지 않는지에 대해 이유를 설명합니다.

배열에 항목 추가하기

push()는 배열을 변경합니다. (원치 않는 방식)

import { useState } from 'react';

let nextId = 0;

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

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setName('');
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

대신 기존에 존재하던 항목들 뒤에 새 항목을 포함하는 새로운 배열을 만드세요. 이를 위한 방법은 여러 가지가 있지만 가장 쉬운 방법은 ... 배열 전개 구문을 사용하는 것입니다.

setArtists( // 아래의 새로운 배열로 state를 변경합니다.
[
...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>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={() => {
        setName('');
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>Add</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>Inspiring sculptors:</h1>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}{' '}
            <button onClick={() => {
              setArtists(
                artists.filter(a =>
                  a.id !== artist.id
                )
              );
            }}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </>
  );
}

”Delete” 버튼을 몇 번 클릭하고, 클릭 이벤트 핸들러를 확인해보세요.

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

여기서 artists.filter(s => s.id !== artist.id)는 “artist.id와 ID가 다른 artists로 구성된 배열을 생성한다”는 의미입니다. 즉, 각 artist의 “Delete” 버튼은 해당 artist를 배열에서 필터링한 다음, 반환된 배열로 리렌더링을 요청합니다. 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}>
        Move circles down!
      </button>
      {shapes.map(shape => (
        <div 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) {
        // 클릭된 counter를 증가시킵니다.
        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>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleClick}>
        Insert
      </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: '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}>
        Reverse
      </button>
      <ul>
        {list.map(artwork => (
          <li key={artwork.id}>{artwork.title}</li>
        ))}
      </ul>
    </>
  );
}

여기서는 먼저 [...list] 전개 구문을 사용해 원본 배열의 복사본을 만듭니다. 이제 복사본이 있으므로 nextList.reverse() 또는 nextList.sort()와 같은 변경 함수를 사용하거나 nextList[0] = "something"과 같이 개별 항목을 할당할 수도 있습니다.

그러나, 배열을 복사하더라도 배열 내부 에 기존 항목을 직접 변경해서는 안됩니다. 이는 얕은 복사이기 때문에 복사한 새 배열에는 원본 배열과 동일한 항목이 포함됩니다. 따라서 복사된 배열 내부의 객체를 수정하면 기존 state가 변경됩니다. 예를 들면, 아래와 같은 코드가 문제가 됩니다.

const nextList = [...list];
nextList[0].seen = true; // 문제: list[0]을 변경시킵니다.
setList(nextList);

nextListlist는 서로 다른 배열이지만, nextList[0]list[0]은 동일한 객체를 가리킵니다. 따라서 nextList[0].seen을 변경하면 list[0].seen도 변경됩니다. 이것은 state 변경이므로 피해야 합니다. 중첩된 JavaScript 객체 업데이트와 유사한 방식으로 이 문제를 해결할 수 있습니다. 변경하려는 개별 항목을 변경하는 대신 복사합니다. 방법은 다음과 같습니다.

배열 내부의 객체 업데이트하기

객체는 실제로 배열 “내부”에 위치하지 않습니다. 코드에서 “내부”로 나타낼 수 있지만 배열의 각 객체는 배열이 “가리키는” 별도의 값입니다. 이것이 list[0]처럼 중첩된 필드를 변경할 때 주의해야 하는 이유입니다. 다른 사람의 artwork 목록이 배열의 동일한 요소를 가리킬 수 있습니다!

중첩된 state를 업데이트할 때, 업데이트하려는 지점부터 최상위 레벨까지의 복사본을 만들어야 합니다. 어떻게 작동하는지 살펴봅시다.

아래 예시에서 두 개의 개별 artwork 목록들은 초기 state가 서로 같습니다. 두 리스트는 분리되어야 하지만 변경으로 인해 두 목록의 state가 실수로 공유되고 한 목록의 체크박스를 선택하면 다른 목록에도 영향을 미칩니다.

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>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</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을 변경하면 원본 artwork 항목이 변경됩니다. 해당 artwork 항목은 yourArtWorks에도 존재하므로 버그가 발생합니다. 이런 버그는 생각하기 어려울 수 있지만 다행히도 state 변경을 피하면 해결할 수 있습니다.

map을 사용하면 이전 항목의 변경 없이 업데이트된 버전으로 대체할 수 있습니다.

setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// 변경된 *새* 객체를 만들어 반환합니다.
return { ...artwork, seen: nextSeen };
} else {
// 변경시키지 않고 반환합니다.
return artwork;
}
}));

여기서 ...객체의 복사본 생성에 사용되는 객체 전개 구문입니다.

이 접근 방식을 사용하면, 기존 state 항목이 변경되지 않고, 버그가 수정됩니다.

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>Art Bucket List</h1>
      <h2>My list of art to see:</h2>
      <ItemList
        artworks={myList}
        onToggle={handleToggleMyList} />
      <h2>Your list of art to see:</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>
  );
}

일반적으로 방금 생성한 객체만 변경해야 합니다. artwork를 삽입하는 경우 변경이 가능하지만, 이미 state에 존재하는 것을 처리하려면 복사본을 만들어야 합니다.

Immer로 간결한 업데이트 로직 작성하기

변경 없이 중첩된 배열을 업데이트하는 것은 객체와 마찬가지로 약간 반복적일 수 있습니다.

  • 일반적으로 깊은 레벨까지의 state를 업데이트할 필요는 없습니다. state 객체가 매우 깊다면 다르게 재구성하여 평평하게 만들 수 있습니다.
  • state 구조를 변경하고 싶지 않다면, Immer 사용할 수 있습니다. 손쉽게 변경 문법을 사용하여 작성할 수 있고 복사본을 생성하여 처리할 수 있습니다.

다음은 Immer로 다시 작성한 Art Bucket List 예시입니다.

{
  "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;
});

이는 원본 state를 변경하는 것이 아니라, Immer에서 제공하는 특수 draft 객체를 변경하기 때문입니다. 마찬가지로 push()pop()같은 변경 함수들도 draft의 컨텐츠에 적용할 수 있습니다.

내부적으로 Immer는 항상 draft에서 수행한 변경 사항에 따라 처음부터 다음 state를 구성합니다. 이렇게 하면 state를 변경하지 않고도 이벤트 핸들러를 매우 간결하게 유지할 수 있습니다.

요약

  • 배열을 state로 만들 수 있지만 변경하면 안됩니다.
  • 배열을 변경하는 대신 배열의 새로운 버전을 만들고, state를 업데이트 해야합니다.
  • [...arr, newItem] 배열 전개 구문을 사용하여 새 항목을 포함한 배열을 생성할 수 있습니다.
  • filter()map()을 사용하여 필터링된 항목들이나 변환된 항목들을 가진 배열을 만들 수 있습니다.
  • Immer를 사용하여 코드 간결성을 유지할 수 있습니다.

챌린지 1 of 4:
장바구니의 항목 업데이트하기

”+” 버튼을 누르면 해당 숫자가 증가하도록 handleIncreaseClick 로직을 채워보세요.

import { useState } from 'react';

const initialProducts = [{
  id: 0,
  name: 'Baklava',
  count: 1,
}, {
  id: 1,
  name: 'Cheese',
  count: 5,
}, {
  id: 2,
  name: 'Spaghetti',
  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>
  );
}