Updating Arrays in State

김동현·2026년 3월 15일

title: 상태에서 배열 업데이트하기

자바스크립트에서 배열은 원래 변경 가능한(mutable) 값이에요. 하지만 이 배열을 state에 저장할 때는 변경 불가능한(immutable) 값처럼 다뤄야 해요. 객체를 다룰 때와 마찬가지로, state에 들어 있는 배열을 업데이트하고 싶다면 기존 배열을 직접 바꾸는 게 아니라 새로운 배열을 만들어야 해요. 또는 기존 배열을 복사한 다음 그 복사본을 사용해도 되고요. 그다음 새 배열을 사용하도록 state를 설정하면 됩니다.

  • React state 안에 있는 배열에서 항목을 추가, 제거, 변경하는 방법
  • 배열 안에 들어 있는 객체를 업데이트하는 방법
  • Immer를 사용해서 배열 복사 코드를 덜 반복적으로 작성하는 방법

변이 없이 배열 업데이트하기 {/updating-arrays-without-mutation/}

자바스크립트에서 배열은 그냥 또 다른 종류의 객체일 뿐이에요. 객체와 마찬가지로, React state 안에 있는 배열은 읽기 전용처럼 다뤄야 해요.
이 말은, arr[0] = 'bird'처럼 배열 내부 항목을 다시 할당하면 안 된다는 뜻이고, push()pop()처럼 배열 자체를 바꾸는 메서드도 사용하면 안 된다는 뜻이에요.

대신 배열을 업데이트하고 싶을 때마다, state 설정 함수에 새로운 배열을 전달해야 해요. 그러려면 state에 들어 있던 원래 배열에서 filter()map() 같은 비변이(non-mutating) 메서드를 호출해서 새 배열을 만들 수 있어요. 그런 다음 결과로 나온 새 배열로 state를 설정하면 됩니다.

아래는 자주 쓰는 배열 연산에 대한 참고 표예요. React state 안에서 배열을 다룰 때는 왼쪽 열에 있는 메서드는 피하고, 오른쪽 열에 있는 방법을 사용하는 게 좋아요:

피하기 (배열을 변이시킴)권장 (새 배열을 반환함)
추가하기push, unshiftconcat, [...arr] 전개 문법 (예제)
제거하기pop, shift, splicefilter, slice (예제)
교체하기splice, arr[i] = ... 할당map (예제)
정렬하기reverse, sort먼저 배열을 복사하기 (예제)

또 다른 방법으로는 Immer를 사용할 수도 있어요. Immer를 쓰면 위 표의 어느 쪽 스타일이든 사용할 수 있게 해줘요.

안타깝게도 slicesplice는 이름이 비슷하지만 완전히 달라요:

  • slice는 배열 전체 또는 배열의 일부를 복사할 수 있게 해줘요.
  • splice는 배열을 변이시켜요. 즉, 항목을 삽입하거나 삭제할 때 원본 배열 자체를 바꿔버려요.

React에서는 state 안의 객체나 배열을 변이시키고 싶지 않기 때문에 splice보다 slice를 훨씬 더 자주 쓰게 될 거예요. (p 없는 slice예요!)
객체 업데이트하기 문서에서는 변이(mutation)가 뭔지, 그리고 왜 state에서는 권장되지 않는지 설명해요.

배열에 항목 추가하기 {/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>
    </>
  );
}
button { margin-left: 5px; }

대신 기존 항목들을 포함하고, 끝에 새 항목 하나를 덧붙인 새 배열을 만들어야 해요. 이걸 하는 방법은 여러 가지가 있는데, 가장 쉬운 건 ... 배열 전개 문법을 쓰는 거예요:

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

배열 전개 문법을 쓰면 원래의 ...artists 앞에 새 항목을 두어서 배열 맨 앞에 항목을 추가할 수도 있어요:

setArtists([
  { id: nextId++, name: name },
  ...artists // 기존 항목들은 뒤로 보냄
]);

이렇게 하면 전개 문법 하나로, 배열 끝에 추가하는 push() 역할도 할 수 있고, 배열 앞에 추가하는 unshift() 역할도 할 수 있어요. 위 샌드박스에서 직접 한번 바꿔보세요!

배열에서 항목 제거하기 {/removing-from-an-array/}

배열에서 항목을 제거하는 가장 쉬운 방법은 그 항목을 걸러내는(filter out) 거예요. 다시 말해, 그 항목을 포함하지 않는 새 배열을 만드는 거죠. 이때는 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(a => a.id !== artist.id)
"artist.id와 다른 ID를 가진 artists만 모아서 배열을 새로 만들어라"라는 뜻이에요.
즉, 각 아티스트의 "Delete" 버튼은 아티스트를 배열에서 걸러낸 다음, 그렇게 만들어진 새 배열로 다시 렌더링해 달라고 요청하는 거예요. 참고로 filter는 원본 배열을 수정하지 않아요.

배열 변환하기 {/transforming-an-array/}

배열의 일부 항목만 바꾸고 싶거나, 전체 항목을 바꾸고 싶다면 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
          key={shape.id}
          style={{
          background: 'purple',
          position: 'absolute',
          left: shape.x,
          top: shape.y,
          borderRadius:
            shape.type === 'circle'
              ? '50%' : '',
          width: 20,
          height: 20,
        }} />
      ))}
    </>
  );
}
body { height: 300px; }

배열 안의 항목 교체하기 {/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) {
        // 클릭한 카운터 증가
        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>
  );
}
button { margin: 5px; }

배열 중간에 삽입하기 {/inserting-into-an-array/}

가끔은 배열 맨 앞도 아니고 맨 뒤도 아닌, 특정 위치에 항목을 넣고 싶을 때가 있어요. 이럴 때는 ... 배열 전개 문법과 slice() 메서드를 같이 사용할 수 있어요. slice()는 배열의 일부를 잘라낸 "조각(slice)"을 만들 수 있게 해줘요. 항목을 삽입하려면, 삽입 위치 이전까지의 조각을 펼치고, 그다음 새 항목을 넣고, 그 뒤에 원래 배열의 나머지 부분을 붙이면 됩니다.

이 예제에서는 Insert 버튼을 누를 때마다 항상 인덱스 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>
    </>
  );
}
button { margin-left: 5px; }

배열에 다른 변경 적용하기 {/making-other-changes-to-an-array/}

전개 문법과 map(), filter() 같은 비변이 메서드만으로는 처리할 수 없는 작업도 있어요. 예를 들어 배열을 뒤집거나 정렬하고 싶을 수 있죠. 그런데 자바스크립트의 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"처럼 개별 항목을 다시 할당해도 돼요.

하지만 배열을 복사했다고 해도, 그 안에 들어 있는 기존 항목 자체를 직접 변이시키면 안 돼요.
왜냐하면 이 복사는 얕은 복사(shallow copy)이기 때문이에요. 새 배열 안에는 원래 배열과 동일한 항목들이 들어 있어요. 그래서 복사한 배열 안의 객체를 수정하면, 결국 기존 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 객체 업데이트하기와 비슷한 방식으로 해결할 수 있어요. 즉, 바꾸고 싶은 개별 항목을 직접 변이시키는 대신 복사해서 바꾸면 돼요. 이제 그 방법을 볼게요.

배열 안에 있는 객체 업데이트하기 {/updating-objects-inside-arrays/}

객체가 정말로 배열 "안에" 들어 있는 건 아니에요. 코드상으로는 안에 있는 것처럼 보여도, 배열 안의 각 객체는 별도의 값이고 배열은 그 값을 "가리키고" 있을 뿐이에요. 그래서 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 항목은 yourList 안에도 들어 있기 때문에 버그가 생기는 거예요. 이런 종류의 버그는 생각하기 꽤 까다로운데, 다행히도 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로 간결한 업데이트 로직 작성하기 {/write-concise-update-logic-with-immer/}

변이 없이 중첩된 배열을 업데이트하다 보면 코드가 조금 반복적으로 느껴질 수 있어요. 객체를 다룰 때와 마찬가지로:

  • 보통 state를 두세 단계보다 더 깊게 업데이트해야 할 일은 많지 않아요. 만약 state 객체가 너무 깊게 중첩되어 있다면, 구조를 다시 잡아서 평평하게(flat) 만드는 게 더 좋을 수 있어요.
  • state 구조를 바꾸고 싶지 않다면, Immer를 사용하는 것도 좋아요. Immer를 쓰면 편리하지만 원래는 변이를 일으키는 문법처럼 코드를 작성할 수 있고, 실제 복사본 생성은 Immer가 대신 처리해줘요.

아래는 Art Bucket List 예제를 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>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>
  );
}
// package.json
{
  "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"
  }
}

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를 사용하면 코드를 더 간결하게 유지할 수 있어요.

장바구니에서 항목 업데이트하기 {/update-an-item-in-the-shopping-cart/}

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>
  );
}
button { margin: 5px; }

map 함수를 사용해서 새 배열을 만들고, ... 객체 전개 문법을 사용해서 변경된 객체의 복사본을 새 배열에 넣으면 돼요:

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) {
    setProducts(products.map(product => {
      if (product.id === productId) {
        return {
          ...product,
          count: product.count + 1
        };
      } else {
        return product;
      }
    }))
  }

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

장바구니에서 항목 제거하기 {/remove-an-item-from-the-shopping-cart/}

이 장바구니에는 "+" 버튼은 잘 동작하지만, "–" 버튼은 아무 일도 하지 않아요. 해당 버튼에 이벤트 핸들러를 추가해서 누르면 해당 상품의 count가 감소하도록 만들어야 해요. 만약 count가 1일 때 "–"를 누르면, 그 상품은 장바구니에서 자동으로 제거되어야 해요. 그리고 count가 0으로 표시되는 일은 절대 없어야 해요.

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) {
    setProducts(products.map(product => {
      if (product.id === productId) {
        return {
          ...product,
          count: product.count + 1
        };
      } else {
        return product;
      }
    }))
  }

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

먼저 map을 사용해서 새 배열을 만들고, 그다음 filter를 사용해서 count0이 된 상품을 제거하면 돼요:

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) {
    setProducts(products.map(product => {
      if (product.id === productId) {
        return {
          ...product,
          count: product.count + 1
        };
      } else {
        return product;
      }
    }))
  }

  function handleDecreaseClick(productId) {
    let nextProducts = products.map(product => {
      if (product.id === productId) {
        return {
          ...product,
          count: product.count - 1
        };
      } else {
        return product;
      }
    });
    nextProducts = nextProducts.filter(p =>
      p.count > 0
    );
    setProducts(nextProducts)
  }

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

비변이 메서드를 사용해서 변이 문제 고치기 {/fix-the-mutations-using-non-mutative-methods/}

이 예제에서는 App.js 안의 모든 이벤트 핸들러가 변이를 사용하고 있어요. 그 결과, todo를 수정하거나 삭제하는 기능이 동작하지 않아요. handleAddTodo, handleChangeTodo, handleDeleteTodo를 비변이 메서드를 사용하도록 다시 작성해보세요:

// src/App.js
import { useState } from 'react';
import AddTodo from './AddTodo.js';
import TaskList from './TaskList.js';

let nextId = 3;
const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Eat tacos', done: false },
  { id: 2, title: 'Brew tea', done: false },
];

export default function TaskApp() {
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAddTodo(title) {
    todos.push({
      id: nextId++,
      title: title,
      done: false
    });
  }

  function handleChangeTodo(nextTodo) {
    const todo = todos.find(t =>
      t.id === nextTodo.id
    );
    todo.title = nextTodo.title;
    todo.done = nextTodo.done;
  }

  function handleDeleteTodo(todoId) {
    const index = todos.findIndex(t =>
      t.id === todoId
    );
    todos.splice(index, 1);
  }

  return (
    <>
      <AddTodo
        onAddTodo={handleAddTodo}
      />
      <TaskList
        todos={todos}
        onChangeTodo={handleChangeTodo}
        onDeleteTodo={handleDeleteTodo}
      />
    </>
  );
}
// src/AddTodo.js
import { useState } from 'react';

export default function AddTodo({ onAddTodo }) {
  const [title, setTitle] = useState('');
  return (
    <>
      <input
        placeholder="Add todo"
        value={title}
        onChange={e => setTitle(e.target.value)}
      />
      <button onClick={() => {
        setTitle('');
        onAddTodo(title);
      }}>Add</button>
    </>
  )
}
// src/TaskList.js
import { useState } from 'react';

export default function TaskList({
  todos,
  onChangeTodo,
  onDeleteTodo
}) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <Task
            todo={todo}
            onChange={onChangeTodo}
            onDelete={onDeleteTodo}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ todo, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let todoContent;
  if (isEditing) {
    todoContent = (
      <>
        <input
          value={todo.title}
          onChange={e => {
            onChange({
              ...todo,
              title: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    todoContent = (
      <>
        {todo.title}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={e => {
          onChange({
            ...todo,
            done: e.target.checked
          });
        }}
      />
      {todoContent}
      <button onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </label>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }

handleAddTodo에서는 배열 전개 문법을 사용할 수 있어요. handleChangeTodo에서는 map으로 새 배열을 만들 수 있고요. handleDeleteTodo에서는 filter로 새 배열을 만들 수 있어요. 이제 리스트가 올바르게 동작해요:

// src/App.js
import { useState } from 'react';
import AddTodo from './AddTodo.js';
import TaskList from './TaskList.js';

let nextId = 3;
const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Eat tacos', done: false },
  { id: 2, title: 'Brew tea', done: false },
];

export default function TaskApp() {
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAddTodo(title) {
    setTodos([
      ...todos,
      {
        id: nextId++,
        title: title,
        done: false
      }
    ]);
  }

  function handleChangeTodo(nextTodo) {
    setTodos(todos.map(t => {
      if (t.id === nextTodo.id) {
        return nextTodo;
      } else {
        return t;
      }
    }));
  }

  function handleDeleteTodo(todoId) {
    setTodos(
      todos.filter(t => t.id !== todoId)
    );
  }

  return (
    <>
      <AddTodo
        onAddTodo={handleAddTodo}
      />
      <TaskList
        todos={todos}
        onChangeTodo={handleChangeTodo}
        onDeleteTodo={handleDeleteTodo}
      />
    </>
  );
}
// src/AddTodo.js
import { useState } from 'react';

export default function AddTodo({ onAddTodo }) {
  const [title, setTitle] = useState('');
  return (
    <>
      <input
        placeholder="Add todo"
        value={title}
        onChange={e => setTitle(e.target.value)}
      />
      <button onClick={() => {
        setTitle('');
        onAddTodo(title);
      }}>Add</button>
    </>
  )
}
// src/TaskList.js
import { useState } from 'react';

export default function TaskList({
  todos,
  onChangeTodo,
  onDeleteTodo
}) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <Task
            todo={todo}
            onChange={onChangeTodo}
            onDelete={onDeleteTodo}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ todo, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let todoContent;
  if (isEditing) {
    todoContent = (
      <>
        <input
          value={todo.title}
          onChange={e => {
            onChange({
              ...todo,
              title: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    todoContent = (
      <>
        {todo.title}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={e => {
          onChange({
            ...todo,
            done: e.target.checked
          });
        }}
      />
      {todoContent}
      <button onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </label>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }

Immer를 사용해서 변이 문제 고치기 {/fix-the-mutations-using-immer/}

이건 바로 이전 챌린지와 같은 예제예요. 이번에는 Immer를 사용해서 변이 문제를 고쳐보세요. 편의를 위해 useImmer는 이미 import되어 있으니, todos state 변수를 그것을 사용하도록 바꾸기만 하면 돼요.

// src/App.js
import { useState } from 'react';
import { useImmer } from 'use-immer';
import AddTodo from './AddTodo.js';
import TaskList from './TaskList.js';

let nextId = 3;
const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Eat tacos', done: false },
  { id: 2, title: 'Brew tea', done: false },
];

export default function TaskApp() {
  const [todos, setTodos] = useState(
    initialTodos
  );

  function handleAddTodo(title) {
    todos.push({
      id: nextId++,
      title: title,
      done: false
    });
  }

  function handleChangeTodo(nextTodo) {
    const todo = todos.find(t =>
      t.id === nextTodo.id
    );
    todo.title = nextTodo.title;
    todo.done = nextTodo.done;
  }

  function handleDeleteTodo(todoId) {
    const index = todos.findIndex(t =>
      t.id === todoId
    );
    todos.splice(index, 1);
  }

  return (
    <>
      <AddTodo
        onAddTodo={handleAddTodo}
      />
      <TaskList
        todos={todos}
        onChangeTodo={handleChangeTodo}
        onDeleteTodo={handleDeleteTodo}
      />
    </>
  );
}
// src/AddTodo.js
import { useState } from 'react';

export default function AddTodo({ onAddTodo }) {
  const [title, setTitle] = useState('');
  return (
    <>
      <input
        placeholder="Add todo"
        value={title}
        onChange={e => setTitle(e.target.value)}
      />
      <button onClick={() => {
        setTitle('');
        onAddTodo(title);
      }}>Add</button>
    </>
  )
}
// src/TaskList.js
import { useState } from 'react';

export default function TaskList({
  todos,
  onChangeTodo,
  onDeleteTodo
}) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <Task
            todo={todo}
            onChange={onChangeTodo}
            onDelete={onDeleteTodo}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ todo, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let todoContent;
  if (isEditing) {
    todoContent = (
      <>
        <input
          value={todo.title}
          onChange={e => {
            onChange({
              ...todo,
              title: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    todoContent = (
      <>
        {todo.title}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={e => {
          onChange({
            ...todo,
            done: e.target.checked
          });
        }}
      />
      {todoContent}
      <button onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </label>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
// package.json
{
  "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"
  }
}

Immer를 사용하면, Immer가 주는 draft의 일부만 바꾸는 한에서는 변이 스타일로 코드를 작성해도 괜찮아요. 여기서는 모든 변이가 draft에 대해 수행되므로 코드가 잘 동작해요:

// src/App.js
import { useState } from 'react';
import { useImmer } from 'use-immer';
import AddTodo from './AddTodo.js';
import TaskList from './TaskList.js';

let nextId = 3;
const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Eat tacos', done: false },
  { id: 2, title: 'Brew tea', done: false },
];

export default function TaskApp() {
  const [todos, updateTodos] = useImmer(
    initialTodos
  );

  function handleAddTodo(title) {
    updateTodos(draft => {
      draft.push({
        id: nextId++,
        title: title,
        done: false
      });
    });
  }

  function handleChangeTodo(nextTodo) {
    updateTodos(draft => {
      const todo = draft.find(t =>
        t.id === nextTodo.id
      );
      todo.title = nextTodo.title;
      todo.done = nextTodo.done;
    });
  }

  function handleDeleteTodo(todoId) {
    updateTodos(draft => {
      const index = draft.findIndex(t =>
        t.id === todoId
      );
      draft.splice(index, 1);
    });
  }

  return (
    <>
      <AddTodo
        onAddTodo={handleAddTodo}
      />
      <TaskList
        todos={todos}
        onChangeTodo={handleChangeTodo}
        onDeleteTodo={handleDeleteTodo}
      />
    </>
  );
}
// src/AddTodo.js
import { useState } from 'react';

export default function AddTodo({ onAddTodo }) {
  const [title, setTitle] = useState('');
  return (
    <>
      <input
        placeholder="Add todo"
        value={title}
        onChange={e => setTitle(e.target.value)}
      />
      <button onClick={() => {
        setTitle('');
        onAddTodo(title);
      }}>Add</button>
    </>
  )
}
// src/TaskList.js
import { useState } from 'react';

export default function TaskList({
  todos,
  onChangeTodo,
  onDeleteTodo
}) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <Task
            todo={todo}
            onChange={onChangeTodo}
            onDelete={onDeleteTodo}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ todo, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let todoContent;
  if (isEditing) {
    todoContent = (
      <>
        <input
          value={todo.title}
          onChange={e => {
            onChange({
              ...todo,
              title: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    todoContent = (
      <>
        {todo.title}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={e => {
          onChange({
            ...todo,
            done: e.target.checked
          });
        }}
      />
      {todoContent}
      <button onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </label>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
// package.json
{
  "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"
  }
}

Immer를 사용할 때도 변이 스타일과 비변이 스타일을 섞어서 사용할 수 있어요.

예를 들어 이 버전에서는 handleAddTodo는 Immer의 draft를 변이시키는 방식으로 구현했고, handleChangeTodohandleDeleteTodo는 비변이 방식인 mapfilter를 사용하고 있어요:

// src/App.js
import { useState } from 'react';
import { useImmer } from 'use-immer';
import AddTodo from './AddTodo.js';
import TaskList from './TaskList.js';

let nextId = 3;
const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Eat tacos', done: false },
  { id: 2, title: 'Brew tea', done: false },
];

export default function TaskApp() {
  const [todos, updateTodos] = useImmer(
    initialTodos
  );

  function handleAddTodo(title) {
    updateTodos(draft => {
      draft.push({
        id: nextId++,
        title: title,
        done: false
      });
    });
  }

  function handleChangeTodo(nextTodo) {
    updateTodos(todos.map(todo => {
      if (todo.id === nextTodo.id) {
        return nextTodo;
      } else {
        return todo;
      }
    }));
  }

  function handleDeleteTodo(todoId) {
    updateTodos(
      todos.filter(t => t.id !== todoId)
    );
  }

  return (
    <>
      <AddTodo
        onAddTodo={handleAddTodo}
      />
      <TaskList
        todos={todos}
        onChangeTodo={handleChangeTodo}
        onDeleteTodo={handleDeleteTodo}
      />
    </>
  );
}
// src/AddTodo.js
import { useState } from 'react';

export default function AddTodo({ onAddTodo }) {
  const [title, setTitle] = useState('');
  return (
    <>
      <input
        placeholder="Add todo"
        value={title}
        onChange={e => setTitle(e.target.value)}
      />
      <button onClick={() => {
        setTitle('');
        onAddTodo(title);
      }}>Add</button>
    </>
  )
}
// src/TaskList.js
import { useState } from 'react';

export default function TaskList({
  todos,
  onChangeTodo,
  onDeleteTodo
}) {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <Task
            todo={todo}
            onChange={onChangeTodo}
            onDelete={onDeleteTodo}
          />
        </li>
      ))}
    </ul>
  );
}

function Task({ todo, onChange, onDelete }) {
  const [isEditing, setIsEditing] = useState(false);
  let todoContent;
  if (isEditing) {
    todoContent = (
      <>
        <input
          value={todo.title}
          onChange={e => {
            onChange({
              ...todo,
              title: e.target.value
            });
          }} />
        <button onClick={() => setIsEditing(false)}>
          Save
        </button>
      </>
    );
  } else {
    todoContent = (
      <>
        {todo.title}
        <button onClick={() => setIsEditing(true)}>
          Edit
        </button>
      </>
    );
  }
  return (
    <label>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={e => {
          onChange({
            ...todo,
            done: e.target.checked
          });
        }}
      />
      {todoContent}
      <button onClick={() => onDelete(todo.id)}>
        Delete
      </button>
    </label>
  );
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
// package.json
{
  "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"
  }
}

Immer를 쓰면 각 상황마다 가장 자연스럽게 느껴지는 스타일을 골라서 사용할 수 있어요.


사이트맵

모든 문서 페이지 개요

profile
프론트에_가까운_풀스택_개발자

0개의 댓글