자바스크립트에서 배열은 원래 변경 가능한(mutable) 값이에요. 하지만 이 배열을 state에 저장할 때는 변경 불가능한(immutable) 값처럼 다뤄야 해요. 객체를 다룰 때와 마찬가지로, state에 들어 있는 배열을 업데이트하고 싶다면 기존 배열을 직접 바꾸는 게 아니라 새로운 배열을 만들어야 해요. 또는 기존 배열을 복사한 다음 그 복사본을 사용해도 되고요. 그다음 새 배열을 사용하도록 state를 설정하면 됩니다.
자바스크립트에서 배열은 그냥 또 다른 종류의 객체일 뿐이에요. 객체와 마찬가지로, React state 안에 있는 배열은 읽기 전용처럼 다뤄야 해요.
이 말은, arr[0] = 'bird'처럼 배열 내부 항목을 다시 할당하면 안 된다는 뜻이고, push()나 pop()처럼 배열 자체를 바꾸는 메서드도 사용하면 안 된다는 뜻이에요.
대신 배열을 업데이트하고 싶을 때마다, state 설정 함수에 새로운 배열을 전달해야 해요. 그러려면 state에 들어 있던 원래 배열에서 filter()나 map() 같은 비변이(non-mutating) 메서드를 호출해서 새 배열을 만들 수 있어요. 그런 다음 결과로 나온 새 배열로 state를 설정하면 됩니다.
아래는 자주 쓰는 배열 연산에 대한 참고 표예요. React state 안에서 배열을 다룰 때는 왼쪽 열에 있는 메서드는 피하고, 오른쪽 열에 있는 방법을 사용하는 게 좋아요:
| 피하기 (배열을 변이시킴) | 권장 (새 배열을 반환함) | |
|---|---|---|
| 추가하기 | push, unshift | concat, [...arr] 전개 문법 (예제) |
| 제거하기 | pop, shift, splice | filter, slice (예제) |
| 교체하기 | splice, arr[i] = ... 할당 | map (예제) |
| 정렬하기 | reverse, sort | 먼저 배열을 복사하기 (예제) |
또 다른 방법으로는 Immer를 사용할 수도 있어요. Immer를 쓰면 위 표의 어느 쪽 스타일이든 사용할 수 있게 해줘요.
안타깝게도 slice와 splice는 이름이 비슷하지만 완전히 달라요:
slice는 배열 전체 또는 배열의 일부를 복사할 수 있게 해줘요.splice는 배열을 변이시켜요. 즉, 항목을 삽입하거나 삭제할 때 원본 배열 자체를 바꿔버려요.React에서는 state 안의 객체나 배열을 변이시키고 싶지 않기 때문에 splice보다 slice를 훨씬 더 자주 쓰게 될 거예요. (p 없는 slice예요!)
객체 업데이트하기 문서에서는 변이(mutation)가 뭔지, 그리고 왜 state에서는 권장되지 않는지 설명해요.
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() 역할도 할 수 있어요. 위 샌드박스에서 직접 한번 바꿔보세요!
배열에서 항목을 제거하는 가장 쉬운 방법은 그 항목을 걸러내는(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는 원본 배열을 수정하지 않아요.
배열의 일부 항목만 바꾸고 싶거나, 전체 항목을 바꾸고 싶다면 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; }
배열 안에서 특정 항목 하나 또는 여러 개를 교체하고 싶을 때가 정말 많아요. 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; }
가끔은 배열 맨 앞도 아니고 맨 뒤도 아닌, 특정 위치에 항목을 넣고 싶을 때가 있어요. 이럴 때는 ... 배열 전개 문법과 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; }
전개 문법과 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);
nextList와 list는 서로 다른 두 배열이지만, nextList[0]과 list[0]은 같은 객체를 가리키고 있어요.
그래서 nextList[0].seen을 바꾸면 list[0].seen도 같이 바뀌게 돼요. 이건 state 변이이고, 피해야 해요! 이 문제는 중첩된 JavaScript 객체 업데이트하기와 비슷한 방식으로 해결할 수 있어요. 즉, 바꾸고 싶은 개별 항목을 직접 변이시키는 대신 복사해서 바꾸면 돼요. 이제 그 방법을 볼게요.
객체가 정말로 배열 "안에" 들어 있는 건 아니에요. 코드상으로는 안에 있는 것처럼 보여도, 배열 안의 각 객체는 별도의 값이고 배열은 그 값을 "가리키고" 있을 뿐이에요. 그래서 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 안에 들어 있는 무언가를 다루고 있다면, 반드시 복사본을 만들어서 작업해야 해요.
변이 없이 중첩된 배열을 업데이트하다 보면 코드가 조금 반복적으로 느껴질 수 있어요. 객체를 다룰 때와 마찬가지로:
아래는 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를 실제로 변이시키지 않으면서도 이벤트 핸들러 코드는 아주 간결하게 유지할 수 있어요.
[...arr, newItem] 배열 전개 문법을 사용하면 새 항목이 포함된 배열을 만들 수 있어요.filter()와 map()을 사용하면 항목이 제거되거나 변환된 새 배열을 만들 수 있어요.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; }
이 장바구니에는 "+" 버튼은 잘 동작하지만, "–" 버튼은 아무 일도 하지 않아요. 해당 버튼에 이벤트 핸들러를 추가해서 누르면 해당 상품의 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를 사용해서 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;
}
}))
}
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; }
이 예제에서는 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를 사용해서 변이 문제를 고쳐보세요. 편의를 위해 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를 변이시키는 방식으로 구현했고, handleChangeTodo와 handleDeleteTodo는 비변이 방식인 map과 filter를 사용하고 있어요:
// 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를 쓰면 각 상황마다 가장 자연스럽게 느껴지는 스타일을 골라서 사용할 수 있어요.