[리액트 공식문서 읽기] ADDING INTERACTIVITY - Updating Arrays in State

JaeHong Jeong·2023년 9월 14일
post-thumbnail

Overview

배열은 JavaScript에서 변경 가능하지만 상태에 저장할 때는 변경할 수 없는 것으로 처리해야 한다. 객체와 마찬가지로 상태에 저장된 배열을 업데이트라면 새 배열을 생성(또는 기존 배열의 복사본 만들기)한 다음 새 배열을 사용하도록 상태를 설정해야 한다.

Updating arrays without mutation

JavaScript에서 배열은 또 다른 종류의 객체일 뿐이다. 객체와 마찬가지로 리액트 상태의 배열도 읽기 전용으로 처리해야한다. 이는 arr[0] = 'bird' 와 같이 배열 내부의 항목을 재할당해서는 안되며, push() 와 pop() 같이 배열을 변경하는 메서드를 사용해서는 안된다는 의미이다.

대신, 배열을 업데이트할 때마다 상태 설정 함수에 새 배열을 전달해야한다. 그렇게 하려면 filter() 와 map() 과 같은 변경되지 않는 메서드를 호출하여 해당 상태의 원래 배열에서 새 배열을 만들 수 있다. 그런 다음 상태를 새 배열로 설정할 수 있다.

다음은 일반적인 배열 작업에 대한 참조 테이블이다. 리액트 상태 내에서 배열을 처리할 때 왼쪽열의 메서드를 피하고 대신 오른쪽 열의 메서드를 사용해야한다.

업로드중..

또는 두 열의 메서드를 모두 사용할 수 있는 Immer를 사용할 수 있다.

💡 Pitfall

안타깝게도 slice 와 splice 는 이름이 비슷하지만 매우 다르다.

  • slice 는 배열이나 그 일부를 복사한다.
  • splice 항목을 삽입하거나 삭제하기 위해 배열을 변형한다.

리액트에서는 상태에 있는 객체나 배열을 변경하고 싶지 않기 때문에 slice (p가 아님)를 훨씬 더 자주 사용하게 될 것이다. 객체 업데이트에서는 mutation이 뭔지 상태에 권장되지 않는 이유를 설명한다.

Adding to an array

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={() => {
        artists.push({
          id: nextId++,
          name: name,
        });
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

대신 기존 항목과 끝에 새 항목을 포함하는 새 배열은 만든다. 이를 수행하는 방법은 여러 가지가 있지만 가장 쉬운 방법은 ... 스프레드 구문을 사용하는 것이다.

setArtists( // Replace the state
  [ // with a new array
    ...artists, // that contains all the old items
    { id: nextId++, name: name } // and one new item at the end
  ]
);

이제 잘 작동할 것 이다.

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={() => {
        setArtists([
          ...artists,
          { id: nextId++, name: name }
        ]);
      }}>Add</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>{artist.name}</li>
        ))}
      </ul>
    </>
  );
}

배열 스프레드 구문을 사용하면 항목을 원래 아티스트 앞에 배치하여 항목 앞에 추가할 수도 있다.

setArtists([
  { id: nextId++, name: name },
  ...artists // Put old items at the end
]);

이러한 방식으로 스프레드는 배열의 끝에 추가하여 push() 작업을 수행하고 배열의 시작 부분에 추가하여 unshift() 작업을 모두 수행할 수 있다.

Removing from an array

배열에서 항목을 제거하는 가장 쉬운 방법은 해당 항목을 필터링하는 것이다. 즉, 해당 항목을 포함하지 않는 새 배열을 생성하게 된다. 이렇게 하려면 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>
    </>
  );
}

“삭제” 버튼을 몇 번 클릭하고 클릭 핸들러를 살펴봐라.

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

여기서 artists.filter(a => a.id !== artist.id) 는 artist.id 와 ID가 다른 artists 로 구성된 배열을 생성한다.는 의미이다. 즉, 각 아티스트의 “삭제” 버튼을 누르면 해당 아티스트를 배열에서 필터링한 다음 결과 배열로 다시 렌더링하도록 요청한다. filter 는 원래 배열을 수정하지 않는다.

Transforming an array

배열의 일부 또는 전체 항목을 변경하려면 map() 을 사용하여 새 배열을 만들 수 있다. map 에 전달할 함수는 데이터나 인덱스(도는 둘다)를 기반으로 각 항목을 어떻게 처리할지 결정할 수 있다.

이 예에서 배열은 두 개의 원과 하나의 정사각형의 좌표를 보유한다. 버튼을 누르면 원만 50px 아래로 이동한다. 이는 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') {
        // No change
        return shape;
      } else {
        // Return a new circle 50px below
        return {
          ...shape,
          y: shape.y + 50,
        };
      }
    });
    // Re-render with the new array
    setShapes(nextShapes);
  }

  return (
    <>
      <button onClick={handleClick}>
        Move circles down!
      </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,
        }} />
      ))}
    </>
  );
}

Replacing items in an array

배열에서 하나 이상의 항목을 바꾸는 것이 특히 일반적이다. 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) {
        // Increment the clicked counter
        return c + 1;
      } else {
        // The rest haven't changed
        return c;
      }
    });
    setCounters(nextCounters);
  }

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

Inserting into an array

때로는 시작도 끝도 아닌 특정 위치에 항목을 삽입하고 싶을 수도 있다. 이렇게 하려면 ... 스프레드 구문을 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; // Could be any index
    const nextArtists = [
      // Items before the insertion point:
      ...artists.slice(0, insertAt),
      // New item:
      { id: nextId++, name: name },
      // Items after the insertion point:
      ...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>
    </>
  );
}

Making other changes to an array

스프레드 구문과 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}>
        Reverse
      </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; // Problem: mutates list[0]
setList(nextList);

nextList 와 list 는 서로 다른 배열이지만 nextList[0] 와 list[0] 은 동일한 객체를 가리킨다. 따라서 nextList[0].seen 을 변경하면 list[0].seen 도 변경된다. 이것은 피해야할 mutation이다. 중첩된 JavaScript 객체를 업데이트하는 것과 비슷한 방법으로 이 문제를 해결할 수 있다. 즉, 변경하는 개별 항목을 변경하는 대신 복하는 것이다. 방법은 다음과 같다.

Updating objects inside arrays

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

중첩된 상태를 업데이트할 때 업데이트하려는 지점부터 최상위 수준까지 복사본을 만들어야한다. 이것이 어떻게 작동하는지 보자.

이 예에서는 두 개의 개별 작품 목록이 동일한 초기 상태를 갖는다. 격리되어야 하지만 mutation으로 인해 상태가 실수로 공유되고 한 목록의 확인란을 선택하면 다른 목록에 영향을 준다.

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; // Problem: mutates an existing item
setMyList(myNextList);

myNextList 배열 자체는 새로운 것이지만 항목 자체는 원래 myList 배열과 동일하다. 따라서 artwork.seen 을 변경하면 원본 아트웍 항목이 변경된다. 해당 삽화 항목도 yourList 에 있으므로 버그가 발생한다. 이와 같은 버그는 생각하기 어려울 수 있지만 다행이도 상태 변경을 피하면 사라진다.

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

setMyList(myList.map(artwork => {
  if (artwork.id === artworkId) {
    // Create a *new* object with changes
    return { ...artwork, seen: nextSeen };
  } else {
    // No changes
    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) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        return artwork;
      }
    }));
  }

  function handleToggleYourList(artworkId, nextSeen) {
    setYourList(yourList.map(artwork => {
      if (artwork.id === artworkId) {
        // Create a *new* object with changes
        return { ...artwork, seen: nextSeen };
      } else {
        // No changes
        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>
  );
}

일반적으로 방금 만든 객체만 변경해야한다. 새로운 작품을 삽입하는 경우에는 이를 변경할 수 있지만 이미 상태에 있는 작품을 다루는 경우에는 복사본을 만들어야한다.

Write concise update logic with Immer

mutation없이 중첩 배열을 업데이트하면 약간 반복될 수 있다.

  • 일반적으로 상태를 몇 수준 이상 업데이트할 필요 없다. 상태 객체가 매우 깊은 경우 평면이 되도록 다르게 재구성하는 것이 좋다.
  • 상태 구조를 변경하고 싶지 않다면 편리하지만 변경 가능한 구문을 사용하여 작성하고 복사본을 생성해 주는 Immer를 사용하는 것이 좋다.

다음은 Immer로 재작성된 Art Bucket List 예시다.

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

Note how with Immer, mutation like artwork.seen = nextSeen is now okay:

Immer을 사용하면 artwork.seen = nextSeen 과 같은 mutation이 괜찮다.

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

원래 상태를 변경하는 것이 아니라 Immer에서 제공하는 특수 draft 객체를 변경하기 때문이다. 마찬가지로 draft 내용에 push() 와 pop() 과 같은 변경 메서드를 적용할 수 있다.

뒤에서 Immer는 항상 draft 에 대한 변경 사항에 따라 처음부터 다음 상태를 구성한다. 이렇게 하면 상태를 변경하지 않고도 이벤트 핸들러를 매우 간결하게 유지할 수 있다.

Recap

  • 배열을 상태로 설정할 수는 있지만 변경할 수는 없다.
  • 대신 새 버전을 만들고 상태를 업데이트해라.
  • [...arr, newItem] 배열 스프레드 구문을 사용하여 새 항목이 포함된 배열을 만들 수 있다.
  • filter() 와 map() 을 사용하여 필터링되거나 변환된 항목이 포함된 새 배열을 만들 수 있다.
  • Immer를 사용하여 코드를 간결하게 유지할 수 있다.
profile
반갑습니다.

0개의 댓글