State에서 배열 업데이트
- Published on
- 리액트에서 배열을 업데이트할 때는 상태를 직접 변이시키지 말고 새로운 배열을 생성하여 상태를 업데이트해야 합니다.
Table of Contents
JavaScript에서 배열은 가변(mutable)이지만, 리액트의 상태에 저장할 때는 불변(immutable)으로 취급해야 합니다. 객체와 마찬가지로, 상태에 저장된 배열을 업데이트할 때는 새로운 배열을 생성하고 상태를 새 배열로 설정해야 합니다.
배울 내용
- 리액트 상태에서 배열에 아이템을 추가, 제거, 변경하는 방법
- 배열 내부의 객체를 업데이트하는 방법
- Immer를 사용하여 배열 복사를 더 간결하게 하는 방법
변이를 사용하지 않고 배열 업데이트하기
JavaScript에서 배열은 객체의 한 종류입니다. 객체와 마찬가지로, 리액트 상태에서 배열은 읽기 전용으로 취급해야 합니다. 이는 arr[0] = 'bird'
와 같이 배열의 항목을 재할당하거나, push()
와 pop()
과 같이 배열을 변이시키는 메서드를 사용해서는 안 된다는 의미입니다.
대신, 배열을 업데이트하고자 할 때마다 상태 설정 함수에 새 배열을 전달해야 합니다. 이를 위해 상태의 원래 배열에서 filter()와 map()과 같은 변이하지 않는 메서드를 호출하여 새 배열을 생성한 다음, 상태를 새 배열로 설정합니다.
다음은 일반적인 배열 작업에 대한 참조 표입니다. 리액트 상태 내부에서 배열을 다룰 때, 왼쪽 열에 있는 메서드는 피하고, 오른쪽 열에 있는 메서드를 사용하는 것이 좋습니다.
피하기(배열을 변이시킴) | 선호하기(새 배열 반환) | |
---|---|---|
추가하기 | push , unshift | concat , [...arr] (예제) |
제거하기 | pop , shift , splice | filter , slice (예제) |
변경하기 | splice, arr[i] = ... 할당 | map (예제) |
정렬하기 | reverse , sort | 먼저 배열을 복사 (예제) |
또는 Immer를 사용할 수도 있습니다. 이는 양쪽 열의 메서드를 모두 사용할 수 있게 해줍니다.
배열에 추가하기
push()
는 배열을 변이시키므로 사용하면 안됩니다.
대신, 기존 아이템과 새 아이템을 포함한 새 배열을 생성합니다. 이를 위해 여러 가지 방법이 있지만, 가장 간단한 방법은 ... 배열 스프레드 구문을 사용하는 것입니다.
setArtists( // 상태를 바꿉니다
[ // 새 배열로 대체합니다
...artists, // 기존 아이템을 포함한 새 배열
{ id: nextId++, name: name } // 새 아이템을 마지막에 추가합니다
]
);
이제 제대로 작동합니다.
배열 스프레드 구문은 ...artists
를 원래 배열 앞에 배치함으로써 아이템을 앞에 추가할 수도 있습니다.
setArtists([
{ id: nextId++, name: name },
...artists // 기존 아이템을 뒤에 추가합니다
]);
이 방법으로 spread 구문은 배열의 끝에 추가하는 push()와 배열의 시작에 추가하는 unshift()의 역할을 할 수 있습니다. 위의 샌드박스에서 한 번 시도해보세요!
배열에서 제거하기
배열에서 아이템을 제거하는 가장 쉬운 방법은 해당 아이템을 필터링하여 제외하는 것입니다. 즉, 해당 아이템을 포함하지 않은 새 배열을 생성합니다. 이를 위해 filter 메서드를 사용할 수 있습니다. 예를 들어:
"Delete" 버튼을 여러 번 클릭하고, 그 클릭 핸들러를 살펴보세요.
setArtists(
artists.filter(a => a.id !== artist.id)
);
여기서 artists.filter(a => a.id !== artist.id)
는 "아티스트의 ID가 artist.id와 다른 아티스트로 구성된 배열을 생성한다"는 의미입니다. 즉, 각 아티스트의 "Delete" 버튼은 해당 아티스트를 배열에서 필터링하고, 결과 배열로 리렌더링을 요청합니다. filter는 원래 배열을 변경하지 않습니다.
배열 변형하기
배열의 일부 또는 모든 아이템을 변경하고자 할 때는 map()
을 사용하여 새로운 배열을 생성할 수 있습니다. map에 전달하는 함수는 데이터나 인덱스(또는 둘 모두)를 기반으로 각 아이템에 대해 수행할 작업을 결정할 수 있습니다.
이 예제에서는 배열이 두 개의 원과 한 개의 사각형의 좌표를 보유하고 있습니다. 버튼을 누를 때, 원만 아래로 50픽셀 이동합니다. 이를 위해 map()
을 사용하여 데이터를 기반으로 새로운 배열을 생성합니다.
배열에서 아이템 교체하기
일부 또는 모든 아이템을 교체하고자 하는 경우, 배열을 변이시키는 할당(arr[0] = 'bird'
)과 같은 방식은 사용해서는 안 됩니다. 대신 map을 사용하여 교체할 수 있습니다.
아이템을 교체하기 위해, map과 함께 새 배열을 생성합니다. map 호출 내부에서 첫 번째 인수로 전달되는 아이템을 반환할지 또는 다른 것을 반환할지를 결정하기 위해 두 번째 인수로 아이템 인덱스를 사용합니다.
배열에 삽입하기
때로는 시작이나 끝이 아닌 특정 위치에 아이템을 삽입하고 싶을 수 있습니다. 이를 위해 ... 배열 스프레드 구문과 slice() 메서드를 함께 사용할 수 있습니다. slice() 메서드는 배열을 "슬라이스"하여 자를 수 있습니다. 아이템을 삽입하기 위해, 삽입 지점 이전의 슬라이스, 그리고 새로운 아이템, 그리고 원래 배열의 나머지를 포함하는 배열을 생성합니다.
이 예제에서 "Insert" 버튼은 항상 인덱스 1에 삽입합니다.
배열에 대한 기타 변경사항
배열을 뒤집거나 정렬하는 등 일부 작업은 스프레드 구문과 map() 및 filter() 같은 변이하지 않는 메서드만으로는 할 수 없는 작업입니다. JavaScript의 reverse()와 sort() 메서드는 원래 배열을 변이시키므로 직접 사용할 수 없습니다.
하지만, 먼저 배열을 복사한 다음 변경을 가할 수 있습니다.
예를 들어:
여기서는 [...list]
스프레드 구문을 사용하여 원래 배열의 사본을 생성합니다. 이제 사본을 사용하여 nextList.reverse() 또는 nextList.sort()와 같은 변이 메서드를 사용하거나, nextList[0] = "something"
과 같이 개별 항목을 할당할 수 있습니다.
그러나 배열을 복사하더라도 기존 항목을 직접 변이시킬 수는 없습니다. 이는 복사가 얕은(shallow) 복사이기 때문입니다. 새 배열은 원래 배열과 동일한 항목을 포함합니다. 따라서 복사된 배열에서 객체를 수정하면 기존 상태를 변이시키는 것입니다. 예를 들어, 다음과 같은 코드는 문제가 됩니다.
const nextList = [...list];
nextList[0].seen = true; // 문제: list[0]을 변이시킴
setList(nextList);
nextList와 list는 두 개의 다른 배열이지만, nextList[0]과 list[0]은 동일한 객체를 가리킵니다. 따라서 nextList[0].seen
을 변경하면 list[0].seen
도 변경됩니다. 이는 상태 변이이므로 피해야 합니다! 이 문제를 해결하기 위해 중첩된 JavaScript 객체 업데이트와 비슷한 방식으로 변경하려는 개별 항목을 복사하는 방법을 사용할 수 있습니다. 다음과 같습니다.
배열 내부의 객체 업데이트하기
객체는 실제로 배열 "내부"에 위치하지 않습니다. 코드에서는 배열 내부에 있는 것처럼 보일 수 있지만, 배열 내의 각 객체는 별개의 값으로 취급됩니다. 따라서 list[0]과 같은 중첩된 필드를 변경할 때 주의해야 합니다. 다른 사람의 작품 목록이 배열의 동일한 요소를 가리킬 수 있기 때문입니다!
중첩된 상태를 업데이트할 때는 업데이트하려는 지점부터 최상위까지 복사본을 생성해야 합니다. 이 작업이 어떻게 이루어지는지 살펴봅시다.
이 예제에서 두 개의 별도의 작품 목록이 동일한 초기 상태를 가지고 있습니다. 이들은 격리되어야 하지만 변이로 인해 상태가 실수로 공유되어 하나의 목록에서 체크박스를 선택하면 다른 목록에도 영향을 미칩니다.
문제는 다음과 같은 코드에 있습니다.
const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // 문제: 기존 항목을 변이시킴
setMyList(myNextList);
myNextList 배열 자체는 새로운 것이지만, 항목들은 원래 myList 배열과 동일합니다. 따라서 artwork.seen을 변경하면 원래 artwork 항목이 변경됩니다. 해당 artwork 항목은 또한 myList에 있으므로 버그가 발생합니다. 이러한 버그는 생각하기 어려울 수 있지만, 다행히도 상태 변이를 피하면 해결됩니다.
map을 사용하여 이전 항목을 업데이트된 버전으로 대체할 수 있습니다.
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// 변경된 항목을 가진 *새로운* 객체를 생성합니다.
return { ...artwork, seen: nextSeen };
} else {
// 변경 없음
return artwork;
}
}));
여기서 ...
은 객체 스프레드 구문을 사용하여 객체의 사본을 생성하는 것입니다.
이 방법을 사용하면 기존 상태 항목을 변이시키지 않으며 버그가 수정됩니다.
일반적으로 새로 생성한 객체만 변이시켜야 합니다. 새로운 작품을 삽입하는 경우에는 변이할 수 있지만, 이미 상태에 있는 항목을 처리할 때는 사본을 만들어야 합니다.
Immer를 사용하여 간결한 업데이트 로직 작성하기
중첩된 배열을 변이하지 않고 업데이트하는 것은 조금 반복적일 수 있습니다. 객체와 마찬가지로:
- 일반적으로 상태를 몇 단계까지 업데이트할 필요는 없습니다. 상태 객체가 매우 깊다면 다른 방식으로 구조를 재조정하여 평평하게 만드는 것이 좋습니다.
- 상태 구조를 변경하지 않으려면 편리하지만 변이 구문을 사용할 수 있는 Immer를 사용하는 것이 좋습니다. 이를 통해 변이 구문을 사용하여 코드를 작성하고, 복사본 생성을 Immer가 처리해줍니다.
다음은 Immer를 사용하여 다시 작성한 Art Bucket List 예제입니다.
Immer를 사용하면 artwork.seen = nextSeen
과 같은 변이가 가능합니다.
updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});
이는 원래 상태를 변이시키지 않고, Immer가 제공하는 특수 draft 객체를 변이합니다. 마찬가지로 draft의 내용에 push()나 pop()과 같은 변이 메서드를 적용할 수도 있습니다.
내부적으로 Immer는 draft에 대한 변경 사항에 따라 항상 다음 상태를 새롭게 구성합니다. 이렇게 하면 상태 변이 없이 이벤트 핸들러를 매우 간결하게 유지할 수 있습니다.
요약
- 배열을 상태에 넣을 수 있지만, 변경해서는 안 됩니다.
- 배열을 변이하는 대신, 새로운 버전의 배열을 생성하고 상태를 해당 배열로 업데이트합니다.
[...arr, newItem]
배열 스프레드 구문을 사용하여 새로운 항목이 있는 배열을 생성할 수 있습니다.filter()
와map()
을 사용하여 필터링된 또는 변형된 항목이 있는 새로운 배열을 생성할 수 있습니다.- 코드를 간결하게 유지하기 위해 Immer를 사용할 수 있습니다.