상태에서 배열 업데이트하기
자바스크립트에서 배열은 변경 가능하지만 state에 저장할 때는 변경 불가능한 것으로 취급해야 합니다. 객체와 마찬가지로 state에 저장된 배열을 업데이트하려면 새 배열을 생성하거나 기존 배열의 복사본을 만든 다음 새 배열을 사용하도록 state를 설정해야 합니다.
학습 내용
- React 상태에서 배열의 항목을 추가, 제거 또는 변경하는 방법
- 배열 내부의 객체를 업데이트하는 방법
- Immer로 배열 복사를 덜 반복적으로 만드는 방법
변이(mutation) 없이 배열 업데이트하기
자바스크립트에서 배열은 또 다른 종류의 객체일 뿐입니다. 객체와 마찬가지로 React 상태의 배열은 읽기 전용으로 취급해야 합니다. 즉, arr[0] = 'bird'와 같이 배열 내부의 항목을 재할당해서는 안 되며, push() 및 pop()과 같이 배열을 변이시키는 메서드도 사용해서는 안 됩니다.
대신 배열을 업데이트할 때마다 상태 설정 함수에 새 배열을 전달해야 합니다. 이렇게 하려면 필터() 및 맵()과 같은 비변환 메서드를 호출하여 상태의 원래 배열에서 새 배열을 만들면 됩니다. 그런 다음 상태를 결과 새 배열로 설정할 수 있습니다.
다음은 일반적인 배열 연산에 대한 참조 표입니다. React 상태 내에서 배열을 다룰 때는 왼쪽 열에 있는 메서드는 피하고 대신 오른쪽 열에 있는 메서드를 선호해야 합니다:
또는 두 열의 메서드를 모두 사용할 수 있는 Immer를 사용할 수도 있습니다.
함정
안타깝게도 슬라이스와 스플라이스는 이름이 비슷하지만 매우 다릅니다:
- 슬라이스는 배열 또는 배열의 일부를 복사할 수 있습니다.
- 스플라이스는 배열을 변경합니다(항목을 삽입하거나 삭제하기 위해).
React에서는 상태의 객체나 배열을 변경하고 싶지 않기 때문에 슬라이스(p! 없음)를 훨씬 더 자주 사용하게 될 것입니다. 객체 업데이트하기에서 변이(mutation)가 무엇이고 왜 상태에 권장되지 않는지 설명했습니다.
배열에 추가하기
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( // 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={() => {
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 // Put old items at the end
]);
이런 식으로 스프레드는 배열의 끝에 추가하는 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>
</>
);
}
'삭제' 버튼을 몇 번 클릭하고 클릭 핸들러를 확인합니다.
setArtists(
artists.filter(a => a.id !== artist.id)
);
여기서 artists.filter(a => a.id !== artist.id)는 "artist.id와 다른 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') {
// 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,
}} />
))}
</>
);
}
배열의 항목 바꾸기
배열에서 하나 이상의 항목을 바꾸고 싶은 경우가 특히 흔합니다. 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>
);
}
배열에 삽입하기
때로는 시작도 끝도 아닌 특정 위치에 항목을 삽입하고 싶을 때가 있습니다. 이를 위해 ... 배열 스프레드 구문과 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>
</>
);
}
배열에 다른 변경 사항 적용하기
스프레드 구문과 map() 및 filter()와 같은 비변환 메서드만으로는 할 수 없는 작업이 몇 가지 있습니다. 예를 들어 배열을 반전시키거나 정렬하고 싶을 수 있습니다. 자바스크립트 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()와 같은 mutation 메서드를 사용하거나 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도 변경됩니다. 이것은 상태 변이이므로 피해야 합니다!
중첩된 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>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을 변경하면 원본 아트웍 항목이 변경됩니다. 해당 아트웍 항목도 yourArtworks에 있으므로 버그가 발생합니다. 이와 같은 버그는 생각하기 어려울 수 있지만 다행히도 상태 변이를 피하면 사라집니다.
map을 사용하여 이전 항목을 변이(mutation) 없이 업데이트된 버전으로 대체할 수 있습니다.
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>
);
}
일반적으로 방금 만든 객체만 변경해야 합니다. 새 아트웍을 삽입하는 경우에는 변경할 수 있지만, 이미 있는 상태의 아트웍을 다루는 경우에는 복사본을 만들어야 합니다.
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 [yourArtworks, 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={yourArtworks}
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;
});
원래 상태를 변경하는 것이 아니라 Immer에서 제공하는 특수한 draft 객체를 변경하는 것이기 때문입니다. 마찬가지로 draft의 콘텐츠에 push() 및 pop()과 같은 변이 메서드를 적용할 수 있습니다.
백그라운드에서 Immer는 항상 사용자가 draft에 적용한 변경 사항에 따라 다음 상태를 처음부터 다시 구성합니다. 따라서 상태를 변경하지 않고도 이벤트 핸들러를 매우 간결하게 유지할 수 있습니다.
요약
- 배열을 상태에 넣을 수는 있지만 변경할 수는 없습니다.
- 배열을 변경하는 대신 배열의 새 버전을 만든 다음 상태를 업데이트하세요.
- 배열 스프레드 구문 [...arr, newItem]을 사용하여 새 항목으로 배열을 만들 수 있습니다.
- filter() 및 map()을 사용하여 필터링되거나 변형된 항목으로 새 배열을 만들 수 있습니다.
- Immer를 사용하여 코드를 간결하게 유지할 수 있습니다.
과제 1 / 4: 장바구니의 품목 업데이트하기
"+"를 누르면 해당 숫자가 증가하도록 handleIncreaseClick 로직을 채웁니다:
과제 2 / 4: 장바구니에서 품목 제거하기
이 장바구니에는 "+" 버튼이 작동하지만 "-" 버튼은 아무 작업도 수행하지 않습니다. 버튼을 누르면 해당 제품의 카운트가 감소하도록 이벤트 핸들러를 추가해야 합니다. 카운트가 1일 때 "-"를 누르면 제품이 카트에서 자동으로 제거되어야 합니다. 0이 표시되지 않도록 하세요.
과제 3 / 4: 비 변이 방법을 사용하여 변이 수정하기
이 예제에서는 App.js의 모든 이벤트 핸들러가 변이를 사용합니다. 따라서 할 일 편집 및 삭제가 작동하지 않습니다. 비변이 메서드를 사용하도록 handleAddTodo, handleChangeTodo 및 handleDeleteTodo를 다시 작성하세요:
과제 4 / 4: Immer를 사용하여 변이 수정하기
이전 챌린지와 동일한 예제입니다. 이번에는 Immer를 사용하여 변이를 수정합니다.
편의를 위해 Immer는 이미 임포트되어 있으므로 이를 사용하려면 todos 상태 변수를 변경해야 합니다.
https://codesandbox.io/s/3juca-lwrk1m?file=/src/UpdatingArraysInState/FixTheMutationsUsingImmer.js
발표요약)
1. state 내 배열도 직접변경 하면 안되고, 불변성 유지를 위해 새로운 배열을 만들어서 setter함수를 써서 변경해야 한다.
2. 새로운 배열을 리턴하는 filter, map 등을 사용하여 원본값을 건들지않고 state를 set한다.
3. Immer를 쓰면 직접 변경이 가능하다.