배열은 JavaScript에서 변경 가능(mutable)하지만 상태를 저장할 때는 변경할 수 없는 것(immutable)으로 처리해야 합니다. 객체와 마찬가지로 상태에 저장된 배열을 업데이트하려면 새 배열을 생성(또는 기존 배열의 복사본 만들기)한 다음 새 배열을 사용하도록 상태를 설정해야 합니다.
JavaScript에서 배열은 또 다른 종류의 객체일 뿐입니다. 객체와 마찬가지로 React 상태의 배열을 읽기 전용으로 처리해야 합니다. 즉, arr[0] = ‘bird’ 와 같은 배열 내부 항목을 재할당해서는 안 되며, push() , pop() 같이 배열을 변경하는 메서드도 사용해서는 안됩니다.
대신, 배열을 업데이트할 때마다 상태 설정 함수에 새 배열을 전달해야 합니다. 그렇게 하려면 filter() 및 map() 와 같은 변경되지 않는 메서드를 호출하여 해당 상태의 원래 배열에서 새 배열을 만들 수 있습니다. 그런 다음 상태를 결과 새 배열로 설정할 수 있습니다.
다음은 일반적인 배열 작업에 대한 참조 테이블입니다. React 상태 내에서 배열을 처리할 때 왼쪽 열의 메서드를 피하고 대신 오른쪽 열의 메서드를 선호해야 합니다.
| avoid (mutates the array) | prefer (returns a new array) | |
|---|---|---|
| adding | push, unshift | concat, [...arr] spread syntax |
| removing | pop, shift, splice | filter, slice |
| replacing | splice, arr[i] = ... assignment | map |
| sorting | reverse, sort | copy the array first |
또는 두 열의 메서드를 모두 사용할 수 있는 Immer를 사용할 수도 있습니다.
slice 와 splice 는 이름이 유사하지만 다음과 같은 차이점이 있습니다.
- slice : 배열이나 그 일부를 복사할 수 있습니다.
- splice : 배열을 변경합니다(항목을 삽입하거나 삭제하기 위해)
React에서는 상태에 있는 객체나 배열을 변경하고 싶지 않기 때문에 slice 를 훨씬 더 자주 사용하게 될 것입니다. 객체 업데이트에서 변형이 무엇인지, 상태에 권장되지 않는 이유를 설명합니다.
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>
</>
);
}
대신 기존 항목과 마지막에 새 항목을 포함하는 새 배열을 만듭니다. 이렇게 하는 방법은 여러 가지가 있지만 가장 쉬운 방법은 … 배열 확산 구문을 사용하는 것입니다.
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>
</>
);
}
배열 확산 구문 사용하면 …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) 는 ID가 artist.id 와 다른 것들로 구성된 artist 배열을 생성한다는 의미입니다. 즉, 각 아티스트의 “삭제” 버튼을 누르면 해당 아티스트를 배열에서 필터링한 다음 결과 배열로 리렌더링을 요청합니다. fliter는 원본 배열을 수정하지 않습니다.
배열의 일부 또는 전체 항목을 변경하려면 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() 같은 비변환 메서드로는 수행할 수 없는 몇 가지 작업이 있습니다. 예를 들어, 배열의 뒤집거나 정렬할 수 있습니다. 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);
// setList(list.toReversed);
}
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 또한 수정됩니다. 이것은 피해야 할 상태 변이입니다. 중첩된 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 을 변경하면 원본 artwork 항목이 변경됩니다. artwork 아이템은 또한 yourList 에도 있으므로 버그가 발생합니다. 이와 같은 버그는 상태 변경을 피하면 사라집니다.
map 을 사용하면 이전 항목을 변형없이 업데이트된 버전으로 대체할 수 있습니다.
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) {
// ES2023에서 도입된 with 메서드 사용 가능
// setMyList(myList.with(artworkId, { ...myList[artworkId], seen: 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로 재작성된 Art Bucket List 예시입니다.
// 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"
},
"devDependencies": {}
}
// App.js
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>
);
}
Immer를 사용하면 다음과 같은 artwork.seen = nextSeen 변이가 가능합니다.
updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});
이는 원래 상태를 변경하는 것이 아니라 Immer에서 제공하는 특수 객체 draft 를 변경하기 때문입니다. 마찬가지로 push() 및 pop() 와 같은 변경 메서드를 콘텐츠에 적용할 수 있습니다.
Immer는 항상 draft 에 대하여 수행한 변경 사항에 따라 처음부터 다음 상태를 구성합니다. 이렇게 하면 상태를 변경하지 않고도 이벤트 핸들러를 매우 간결하게 유지할 수 있습니다.