안녕하세요! 상태(State)를 어떻게 구조화하느냐는, 나중에 코드를 수정하고 디버깅하기 즐거운 컴포넌트가 될지, 아니면 끊임없이 버그를 뿜어내는 골칫덩이가 될지를 결정하는 아주 중요한 차이를 만듭니다. 이번 시간에는 여러분이 상태를 구조화할 때 꼭 고려해야 할 몇 가지 유용한 팁들을 살펴볼게요.
상태를 가지는 컴포넌트를 작성하다 보면, "상태 변수를 몇 개나 만들어야 하지?", "데이터 형태(shape)는 어떻게 잡는 게 좋을까?" 같은 고민에 빠지게 될 거예요. 물론 최적화되지 않은 상태 구조로도 올바르게 동작하는 프로그램을 짤 수는 있지만, 더 나은 선택을 할 수 있도록 도와주는 몇 가지 원칙이 있습니다. 프론트엔드 개발자로 성장하면서 두고두고 써먹을 수 있는 원칙들이니 잘 기억해 두세요!
이 원칙들의 핵심 목표는 바로 '실수를 유발하지 않으면서 상태를 업데이트하기 쉽게 만드는 것'입니다. 상태에서 불필요하고 중복된 데이터를 제거하면 모든 데이터 조각이 항상 동기화된 상태를 유지하도록 보장할 수 있어요. 이건 마치 데이터베이스 엔지니어가 버그 발생 확률을 줄이기 위해 데이터베이스 구조를 "정규화(normalize)"하는 것과 비슷하답니다. 알베르트 아인슈타인의 말을 빌리자면, "상태를 가능한 한 단순하게 만드세요. 하지만 그보다 더 단순하게 만들지는 마세요."
자, 그럼 이 원칙들이 실제 코드에서 어떻게 적용되는지 하나씩 살펴볼까요?
단일 상태 변수를 쓸지, 아니면 여러 개의 상태 변수를 쓸지 확신이 서지 않을 때가 있을 거예요.
이렇게 하는 게 좋을까요?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
아니면 이렇게 하는 게 좋을까요?
const [position, setPosition] = useState({ x: 0, y: 0 });
기술적으로는 두 가지 방법 모두 사용할 수 있습니다. 하지만 만약 두 개의 상태 변수가 항상 함께 변경된다면, 이들을 하나의 상태 변수로 묶는 것이 좋은 아이디어일 수 있어요. 그렇게 하면 둘 중 하나만 업데이트하고 깜빡하는 실수를 방지할 수 있으니까요. 아래 예시를 보세요. 커서를 움직이면 빨간 점의 x, y 좌표가 항상 함께 업데이트됩니다.
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
return (
<div
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}} />
</div>
)
}
body { margin: 0; padding: 0; height: 250px; }
데이터를 객체나 배열로 묶는 게 좋은 또 다른 경우는 필요한 상태 조각이 몇 개나 될지 미리 알 수 없을 때예요. 사용자가 직접 커스텀 필드를 추가할 수 있는 폼(form) 같은 것을 만들 때 특히 유용하죠.
상태 변수가 객체인 경우, 다른 필드들을 명시적으로 복사하지 않고는 특정 필드 하나만 업데이트할 수 없다는 점을 꼭 기억하세요! 예를 들어 위 코드에서 setPosition({ x: 100 })이라고 해버리면 y 속성이 아예 날아가 버립니다. 만약 x만 변경하고 싶다면 setPosition({ ...position, x: 100 }) 처럼 기존 객체를 복사해서 써야 하거나, 아니면 아예 상태 변수를 두 개로 나눠서 setX(100)을 호출해야 합니다.
여기 isSending과 isSent라는 두 개의 상태 변수를 가진 호텔 피드백 폼이 있어요:
import { useState } from 'react';
export default function FeedbackForm() {
const [text, setText] = useState('');
const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setIsSending(true);
await sendMessage(text);
setIsSending(false);
setIsSent(true);
}
if (isSent) {
return <h1>Thanks for feedback!</h1>
}
return (
<form onSubmit={handleSubmit}>
<p>How was your stay at The Prancing Pony?</p>
<textarea
disabled={isSending}
value={text}
onChange={e => setText(e.target.value)}
/>
<br />
<button
disabled={isSending}
type="submit"
>
Send
</button>
{isSending && <p>Sending...</p>}
</form>
);
}
// Pretend to send a message.
function sendMessage(text) {
return new Promise(resolve => {
setTimeout(resolve, 2000);
});
}
이 코드는 당장은 잘 작동하지만, "불가능한" 상태가 만들어질 여지를 남겨두고 있어요. 예를 들어, setIsSent와 setIsSending을 함께 호출하는 걸 깜빡한다면, isSending과 isSent가 동시에 true가 되는 이상한 상황에 빠질 수 있습니다. 컴포넌트가 복잡해질수록 도대체 무슨 일이 일어난 건지 파악하기가 더 힘들어지죠.
isSending과 isSent는 결코 동시에 true가 될 수 없기 때문에, 차라리 이 둘을 세 가지 유효한 상태 중 하나를 가지는 단일 status 상태 변수로 교체하는 것이 훨씬 좋습니다: 'typing' (초기 상태), 'sending', 그리고 'sent'로 말이죠!
import { useState } from 'react';
export default function FeedbackForm() {
const [text, setText] = useState('');
const [status, setStatus] = useState('typing');
async function handleSubmit(e) {
e.preventDefault();
setStatus('sending');
await sendMessage(text);
setStatus('sent');
}
const isSending = status === 'sending';
const isSent = status === 'sent';
if (isSent) {
return <h1>Thanks for feedback!</h1>
}
return (
<form onSubmit={handleSubmit}>
<p>How was your stay at The Prancing Pony?</p>
<textarea
disabled={isSending}
value={text}
onChange={e => setText(e.target.value)}
/>
<br />
<button
disabled={isSending}
type="submit"
>
Send
</button>
{isSending && <p>Sending...</p>}
</form>
);
}
// Pretend to send a message.
function sendMessage(text) {
return new Promise(resolve => {
setTimeout(resolve, 2000);
});
}
코드의 가독성을 위해 상수를 선언해서 쓸 수도 있어요:
const isSending = status === 'sending';
const isSent = status === 'sent';
이 상수들은 상태 변수가 아니기 때문에, 서로 동기화가 어긋날까 봐 걱정할 필요가 전혀 없답니다!
렌더링 도중에 컴포넌트의 props나 기존 상태 변수들을 이용해 계산해 낼 수 있는 정보라면, 그 정보는 컴포넌트의 상태에 넣지 않는 것이 원칙입니다.
예를 들어, 아래 폼을 볼까요? 동작은 잘 하지만, 혹시 불필요하게 선언된 상태 변수를 찾으셨나요?
import { useState } from 'react';
export default function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
function handleFirstNameChange(e) {
setFirstName(e.target.value);
setFullName(e.target.value + ' ' + lastName);
}
function handleLastNameChange(e) {
setLastName(e.target.value);
setFullName(firstName + ' ' + e.target.value);
}
return (
<>
<h2>Let’s check you in</h2>
<label>
First name:{' '}
<input
value={firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:{' '}
<input
value={lastName}
onChange={handleLastNameChange}
/>
</label>
<p>
Your ticket will be issued to: <b>{fullName}</b>
</p>
</>
);
}
label { display: block; margin-bottom: 5px; }
이 폼에는 firstName, lastName, 그리고 fullName 이렇게 세 개의 상태 변수가 있습니다. 하지만 눈치채셨겠지만 fullName은 불필요한 상태예요. 렌더링하는 동안 firstName과 lastName을 합쳐서 언제든지 fullName을 계산해 낼 수 있거든요. 그러니 상태에서 과감하게 제거하세요.
수정된 코드는 다음과 같습니다:
import { useState } from 'react';
export default function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = firstName + ' ' + lastName;
function handleFirstNameChange(e) {
setFirstName(e.target.value);
}
function handleLastNameChange(e) {
setLastName(e.target.value);
}
return (
<>
<h2>Let’s check you in</h2>
<label>
First name:{' '}
<input
value={firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:{' '}
<input
value={lastName}
onChange={handleLastNameChange}
/>
</label>
<p>
Your ticket will be issued to: <b>{fullName}</b>
</p>
</>
);
}
label { display: block; margin-bottom: 5px; }
이제 fullName은 더 이상 상태 변수가 아닙니다. 대신 렌더링 도중에 실시간으로 계산되죠:
const fullName = firstName + ' ' + lastName;
그 결과, 변경을 처리하는 핸들러 함수에서 fullName을 업데이트하기 위해 따로 특별한 코드를 작성할 필요가 없어졌습니다. setFirstName이나 setLastName을 호출하면 리렌더링이 트리거되고, 그다음 렌더링에서 최신 데이터를 바탕으로 새로운 fullName이 자동으로 계산될 테니까요.
강사로서 꼭 짚고 넘어가고 싶은 내용이네요! 실무에서 주니어 개발자들이 정말 흔하게 만드는 불필요한 상태의 예시가 바로 이런 코드입니다:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
여기서 color라는 상태 변수는 messageColor prop을 이용해 초기화됩니다. 이 방식의 치명적인 문제는 나중에 부모 컴포넌트가 messageColor의 값을 다르게 전달해도 (예를 들어 'blue'에서 'red'로 변경해도), color 상태 변수는 업데이트되지 않는다는 점입니다! 상태는 오직 컴포넌트의 첫 번째 렌더링 시점에만 초기화되기 때문이에요.
이런 이유로 prop을 상태 변수에 그대로 "미러링(mirroring)"하는 것은 큰 혼란과 버그를 초래할 수 있습니다. 대신, 코드에서 messageColor prop을 직접 사용하세요. 만약 이름이 너무 길어서 짧은 이름을 쓰고 싶다면, 아래처럼 상수를 선언해서 쓰면 됩니다:
function Message({ messageColor }) {
const color = messageColor;
이렇게 하면 부모 컴포넌트에서 전달된 prop과 동기화가 어긋날 일이 전혀 없죠.
prop을 상태로 "미러링"하는 것이 허용되는 유일한 경우는, 특정 prop에 대한 모든 추가 업데이트를 의도적으로 무시하고 싶을 때뿐입니다. 이런 경우에는 컨벤션상 prop 이름 앞에 initial이나 default를 붙여서 새로운 값들이 무시된다는 의도를 명확히 표현해야 합니다:
function Message({ initialColor }) {
// `color` 상태 변수는 `initialColor`의 *첫 번째* 값만을 가집니다.
// 이후에 `initialColor` prop이 변경되더라도 무시됩니다.
const [color, setColor] = useState(initialColor);
아래의 메뉴 리스트 컴포넌트는 여러 개의 여행 간식 중 하나를 선택할 수 있게 해줍니다:
import { useState } from 'react';
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(
items[0]
);
return (
<>
<h2>What's your travel snack?</h2>
<ul>
{items.map(item => (
<li key={item.id}>
{item.title}
{' '}
<button onClick={() => {
setSelectedItem(item);
}}>Choose</button>
</li>
))}
</ul>
<p>You picked {selectedItem.title}.</p>
</>
);
}
button { margin-top: 10px; }
현재 코드는 선택된 아이템을 selectedItem 상태 변수에 객체 형태로 저장하고 있어요. 하지만 이건 좋은 방법이 아닙니다. selectedItem에 들어있는 내용이 items 리스트 안에 있는 아이템 중 하나의 객체와 완벽하게 동일하기 때문이죠. 즉, 특정 아이템에 대한 정보가 두 곳에 중복해서 저장되고 있다는 뜻입니다.
이게 왜 문제일까요? 각 아이템을 수정할 수 있도록 기능을 추가해 볼게요:
import { useState } from 'react';
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedItem, setSelectedItem] = useState(
items[0]
);
function handleItemChange(id, e) {
setItems(items.map(item => {
if (item.id === id) {
return {
...item,
title: e.target.value,
};
} else {
return item;
}
}));
}
return (
<>
<h2>What's your travel snack?</h2>
<ul>
{items.map((item, index) => (
<li key={item.id}>
<input
value={item.title}
onChange={e => {
handleItemChange(item.id, e)
}}
/>
{' '}
<button onClick={() => {
setSelectedItem(item);
}}>Choose</button>
</li>
))}
</ul>
<p>You picked {selectedItem.title}.</p>
</>
);
}
button { margin-top: 10px; }
문제점을 발견하셨나요? 만약 어떤 아이템을 "Choose(선택)" 한 다음에 인풋창에서 텍스트를 수정하면, 인풋창의 내용은 업데이트되지만 맨 아래에 있는 메시지 줄은 수정된 내용을 반영하지 못합니다. 상태가 중복되어 있는데, 아이템 리스트(items)를 수정하면서 selectedItem도 같이 업데이트하는 걸 깜빡했기 때문이에요.
물론 selectedItem도 같이 업데이트하도록 코드를 짤 수는 있겠지만, 더 근본적이고 쉬운 해결책은 중복 자체를 없애는 것입니다. 이 예제에서는 객체 전체를 저장하는 selectedItem 대신 (이게 바로 items 안의 객체와 중복을 만드니까요!), selectedId를 상태로 가지고 있고 그다음 렌더링 시에 해당 ID를 가진 아이템을 items 배열에서 찾아서(search) 사용하는 방식을 권장합니다:
import { useState } from 'react';
const initialItems = [
{ title: 'pretzels', id: 0 },
{ title: 'crispy seaweed', id: 1 },
{ title: 'granola bar', id: 2 },
];
export default function Menu() {
const [items, setItems] = useState(initialItems);
const [selectedId, setSelectedId] = useState(0);
const selectedItem = items.find(item =>
item.id === selectedId
);
function handleItemChange(id, e) {
setItems(items.map(item => {
if (item.id === id) {
return {
...item,
title: e.target.value,
};
} else {
return item;
}
}));
}
return (
<>
<h2>What's your travel snack?</h2>
<ul>
{items.map((item, index) => (
<li key={item.id}>
<input
value={item.title}
onChange={e => {
handleItemChange(item.id, e)
}}
/>
{' '}
<button onClick={() => {
setSelectedId(item.id);
}}>Choose</button>
</li>
))}
</ul>
<p>You picked {selectedItem.title}.</p>
</>
);
}
button { margin-top: 10px; }
수정 전에는 상태가 이렇게 중복되어 있었죠:
items = [{ id: 0, title: 'pretzels'}, ...]selectedItem = {id: 0, title: 'pretzels'}수정 후에는 이렇게 바뀌었습니다:
items = [{ id: 0, title: 'pretzels'}, ...]selectedId = 0중복이 말끔히 사라졌고, 필수적인 상태만 남게 되었어요!
이제 선택된 아이템의 이름을 수정해 보면, 아래 메시지도 즉각적으로 업데이트되는 것을 볼 수 있습니다. setItems가 리렌더링을 유발하고, items.find(...) 로직이 수정된 타이틀을 가진 아이템을 다시 찾아내기 때문이죠. 우리는 선택된 아이템 그 자체를 상태에 들고 있을 필요가 없었습니다. 유일하게 필수적인 정보는 선택된 아이템의 ID 뿐이니까요. 나머지는 렌더링 중에 충분히 계산해 낼 수 있습니다.
행성, 대륙, 그리고 국가들로 구성된 여행 계획 데이터를 만든다고 상상해 보세요. 아래 예시처럼 중첩된 객체와 배열을 사용해서 상태 구조를 짜고 싶은 유혹에 빠지기 쉽습니다:
// src/App.js
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
function PlaceTree({ place }) {
const childPlaces = place.childPlaces;
return (
<li>
{place.title}
{childPlaces.length > 0 && (
<ol>
{childPlaces.map(place => (
<PlaceTree key={place.id} place={place} />
))}
</ol>
)}
</li>
);
}
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
const planets = plan.childPlaces;
return (
<>
<h2>Places to visit</h2>
<ol>
{planets.map(place => (
<PlaceTree key={place.id} place={place} />
))}
</ol>
</>
);
}
// src/places.js
export const initialTravelPlan = {
id: 0,
title: '(Root)',
childPlaces: [{
id: 1,
title: 'Earth',
childPlaces: [{
id: 2,
title: 'Africa',
childPlaces: [{
id: 3,
title: 'Botswana',
childPlaces: []
}, {
id: 4,
title: 'Egypt',
childPlaces: []
}, {
id: 5,
title: 'Kenya',
childPlaces: []
}, {
id: 6,
title: 'Madagascar',
childPlaces: []
}, {
id: 7,
title: 'Morocco',
childPlaces: []
}, {
id: 8,
title: 'Nigeria',
childPlaces: []
}, {
id: 9,
title: 'South Africa',
childPlaces: []
}]
}, {
id: 10,
title: 'Americas',
childPlaces: [{
id: 11,
title: 'Argentina',
childPlaces: []
}, {
id: 12,
title: 'Brazil',
childPlaces: []
}, {
id: 13,
title: 'Barbados',
childPlaces: []
}, {
id: 14,
title: 'Canada',
childPlaces: []
}, {
id: 15,
title: 'Jamaica',
childPlaces: []
}, {
id: 16,
title: 'Mexico',
childPlaces: []
}, {
id: 17,
title: 'Trinidad and Tobago',
childPlaces: []
}, {
id: 18,
title: 'Venezuela',
childPlaces: []
}]
}, {
id: 19,
title: 'Asia',
childPlaces: [{
id: 20,
title: 'China',
childPlaces: []
}, {
id: 21,
title: 'India',
childPlaces: []
}, {
id: 22,
title: 'Singapore',
childPlaces: []
}, {
id: 23,
title: 'South Korea',
childPlaces: []
}, {
id: 24,
title: 'Thailand',
childPlaces: []
}, {
id: 25,
title: 'Vietnam',
childPlaces: []
}]
}, {
id: 26,
title: 'Europe',
childPlaces: [{
id: 27,
title: 'Croatia',
childPlaces: [],
}, {
id: 28,
title: 'France',
childPlaces: [],
}, {
id: 29,
title: 'Germany',
childPlaces: [],
}, {
id: 30,
title: 'Italy',
childPlaces: [],
}, {
id: 31,
title: 'Portugal',
childPlaces: [],
}, {
id: 32,
title: 'Spain',
childPlaces: [],
}, {
id: 33,
title: 'Turkey',
childPlaces: [],
}]
}, {
id: 34,
title: 'Oceania',
childPlaces: [{
id: 35,
title: 'Australia',
childPlaces: [],
}, {
id: 36,
title: 'Bora Bora (French Polynesia)',
childPlaces: [],
}, {
id: 37,
title: 'Easter Island (Chile)',
childPlaces: [],
}, {
id: 38,
title: 'Fiji',
childPlaces: [],
}, {
id: 39,
title: 'Hawaii (the USA)',
childPlaces: [],
}, {
id: 40,
title: 'New Zealand',
childPlaces: [],
}, {
id: 41,
title: 'Vanuatu',
childPlaces: [],
}]
}]
}, {
id: 42,
title: 'Moon',
childPlaces: [{
id: 43,
title: 'Rheita',
childPlaces: []
}, {
id: 44,
title: 'Piccolomini',
childPlaces: []
}, {
id: 45,
title: 'Tycho',
childPlaces: []
}]
}, {
id: 46,
title: 'Mars',
childPlaces: [{
id: 47,
title: 'Corn Town',
childPlaces: []
}, {
id: 48,
title: 'Green Hill',
childPlaces: []
}]
}]
};
자, 이제 여러분이 이미 방문했던 장소를 삭제하는 버튼을 추가하고 싶다고 해볼게요. 어떻게 구현해야 할까요? 중첩된 객체를 업데이트하는 과정은 변경된 부분부터 최상위 루트까지 올라가면서 모든 객체의 복사본을 만들어야 한다는 것을 의미합니다. 저 깊은 곳에 있는 장소를 삭제하려면, 그 장소를 포함하는 모든 부모 경로상의 객체들을 죄다 복사해야 해요. 코드가 엄청나게 장황해지겠죠. (강사 팁: 실제 실무에서 백엔드가 주는 트리 데이터를 그대로 쓰다가 지옥을 맛보는 경우가 많습니다!)
상태가 너무 깊게 중첩되어 업데이트하기가 까다롭다면, 상태를 "평탄하게(flat)" 만드는 것을 진지하게 고려해 보세요. 데이터를 재구조화하는 좋은 방법이 있어요. 각 place 객체가 자식 장소들의 객체 배열을 직접 들고 있는 트리 구조 대신, 각 place가 자식 장소들의 ID 배열만을 들고 있게 바꾸는 거예요. 그리고 각 장소의 ID를 해당 장소 객체로 매핑해 주는 별도의 객체(Dictionary 형태)를 하나 저장하는 거죠.
이런 데이터 구조 변경은 여러분에게 데이터베이스 테이블을 떠올리게 할 수도 있습니다:
// src/App.js
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
function PlaceTree({ id, placesById }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
{childIds.length > 0 && (
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
placesById={placesById}
/>
))}
</ol>
)}
</li>
);
}
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
placesById={plan}
/>
))}
</ol>
</>
);
}
// src/places.js
export const initialTravelPlan = {
0: {
id: 0,
title: '(Root)',
childIds: [1, 42, 46],
},
1: {
id: 1,
title: 'Earth',
childIds: [2, 10, 19, 26, 34]
},
2: {
id: 2,
title: 'Africa',
childIds: [3, 4, 5, 6 , 7, 8, 9]
},
3: {
id: 3,
title: 'Botswana',
childIds: []
},
4: {
id: 4,
title: 'Egypt',
childIds: []
},
5: {
id: 5,
title: 'Kenya',
childIds: []
},
6: {
id: 6,
title: 'Madagascar',
childIds: []
},
7: {
id: 7,
title: 'Morocco',
childIds: []
},
8: {
id: 8,
title: 'Nigeria',
childIds: []
},
9: {
id: 9,
title: 'South Africa',
childIds: []
},
10: {
id: 10,
title: 'Americas',
childIds: [11, 12, 13, 14, 15, 16, 17, 18],
},
11: {
id: 11,
title: 'Argentina',
childIds: []
},
12: {
id: 12,
title: 'Brazil',
childIds: []
},
13: {
id: 13,
title: 'Barbados',
childIds: []
},
14: {
id: 14,
title: 'Canada',
childIds: []
},
15: {
id: 15,
title: 'Jamaica',
childIds: []
},
16: {
id: 16,
title: 'Mexico',
childIds: []
},
17: {
id: 17,
title: 'Trinidad and Tobago',
childIds: []
},
18: {
id: 18,
title: 'Venezuela',
childIds: []
},
19: {
id: 19,
title: 'Asia',
childIds: [20, 21, 22, 23, 24, 25],
},
20: {
id: 20,
title: 'China',
childIds: []
},
21: {
id: 21,
title: 'India',
childIds: []
},
22: {
id: 22,
title: 'Singapore',
childIds: []
},
23: {
id: 23,
title: 'South Korea',
childIds: []
},
24: {
id: 24,
title: 'Thailand',
childIds: []
},
25: {
id: 25,
title: 'Vietnam',
childIds: []
},
26: {
id: 26,
title: 'Europe',
childIds: [27, 28, 29, 30, 31, 32, 33],
},
27: {
id: 27,
title: 'Croatia',
childIds: []
},
28: {
id: 28,
title: 'France',
childIds: []
},
29: {
id: 29,
title: 'Germany',
childIds: []
},
30: {
id: 30,
title: 'Italy',
childIds: []
},
31: {
id: 31,
title: 'Portugal',
childIds: []
},
32: {
id: 32,
title: 'Spain',
childIds: []
},
33: {
id: 33,
title: 'Turkey',
childIds: []
},
34: {
id: 34,
title: 'Oceania',
childIds: [35, 36, 37, 38, 39, 40, 41],
},
35: {
id: 35,
title: 'Australia',
childIds: []
},
36: {
id: 36,
title: 'Bora Bora (French Polynesia)',
childIds: []
},
37: {
id: 37,
title: 'Easter Island (Chile)',
childIds: []
},
38: {
id: 38,
title: 'Fiji',
childIds: []
},
39: {
id: 40,
title: 'Hawaii (the USA)',
childIds: []
},
40: {
id: 40,
title: 'New Zealand',
childIds: []
},
41: {
id: 41,
title: 'Vanuatu',
childIds: []
},
42: {
id: 42,
title: 'Moon',
childIds: [43, 44, 45]
},
43: {
id: 43,
title: 'Rheita',
childIds: []
},
44: {
id: 44,
title: 'Piccolomini',
childIds: []
},
45: {
id: 45,
title: 'Tycho',
childIds: []
},
46: {
id: 46,
title: 'Mars',
childIds: [47, 48]
},
47: {
id: 47,
title: 'Corn Town',
childIds: []
},
48: {
id: 48,
title: 'Green Hill',
childIds: []
}
};
이제 상태가 "평탄화(정규화)"되었으니, 중첩된 아이템을 업데이트하는 것이 훨씬 수월해졌습니다.
어떤 장소를 지우고 싶다면 상태를 두 단계만 업데이트하면 됩니다:
childIds 배열을 가지도록 해당 장소의 부모 장소를 업데이트합니다.구체적인 구현 예시를 보여드릴게요:
// src/App.js
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
function handleComplete(parentId, childId) {
const parent = plan[parentId];
// 이 자식 ID를 포함하지 않는
// 새로운 버전의 부모 장소 객체를 만듭니다.
const nextParent = {
...parent,
childIds: parent.childIds
.filter(id => id !== childId)
};
// 루트 상태 객체를 업데이트하여...
setPlan({
...plan,
// ...업데이트된 부모 정보를 가지도록 합니다.
[parentId]: nextParent
});
}
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
parentId={0}
placesById={plan}
onComplete={handleComplete}
/>
))}
</ol>
</>
);
}
function PlaceTree({ id, parentId, placesById, onComplete }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
<button onClick={() => {
onComplete(parentId, id);
}}>
Complete
</button>
{childIds.length > 0 &&
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
parentId={id}
placesById={placesById}
onComplete={onComplete}
/>
))}
</ol>
}
</li>
);
}
// src/places.js
export const initialTravelPlan = {
0: {
id: 0,
title: '(Root)',
childIds: [1, 42, 46],
},
1: {
id: 1,
title: 'Earth',
childIds: [2, 10, 19, 26, 34]
},
2: {
id: 2,
title: 'Africa',
childIds: [3, 4, 5, 6 , 7, 8, 9]
},
3: {
id: 3,
title: 'Botswana',
childIds: []
},
4: {
id: 4,
title: 'Egypt',
childIds: []
},
5: {
id: 5,
title: 'Kenya',
childIds: []
},
6: {
id: 6,
title: 'Madagascar',
childIds: []
},
7: {
id: 7,
title: 'Morocco',
childIds: []
},
8: {
id: 8,
title: 'Nigeria',
childIds: []
},
9: {
id: 9,
title: 'South Africa',
childIds: []
},
10: {
id: 10,
title: 'Americas',
childIds: [11, 12, 13, 14, 15, 16, 17, 18],
},
11: {
id: 11,
title: 'Argentina',
childIds: []
},
12: {
id: 12,
title: 'Brazil',
childIds: []
},
13: {
id: 13,
title: 'Barbados',
childIds: []
},
14: {
id: 14,
title: 'Canada',
childIds: []
},
15: {
id: 15,
title: 'Jamaica',
childIds: []
},
16: {
id: 16,
title: 'Mexico',
childIds: []
},
17: {
id: 17,
title: 'Trinidad and Tobago',
childIds: []
},
18: {
id: 18,
title: 'Venezuela',
childIds: []
},
19: {
id: 19,
title: 'Asia',
childIds: [20, 21, 22, 23, 24, 25,],
},
20: {
id: 20,
title: 'China',
childIds: []
},
21: {
id: 21,
title: 'India',
childIds: []
},
22: {
id: 22,
title: 'Singapore',
childIds: []
},
23: {
id: 23,
title: 'South Korea',
childIds: []
},
24: {
id: 24,
title: 'Thailand',
childIds: []
},
25: {
id: 25,
title: 'Vietnam',
childIds: []
},
26: {
id: 26,
title: 'Europe',
childIds: [27, 28, 29, 30, 31, 32, 33],
},
27: {
id: 27,
title: 'Croatia',
childIds: []
},
28: {
id: 28,
title: 'France',
childIds: []
},
29: {
id: 29,
title: 'Germany',
childIds: []
},
30: {
id: 30,
title: 'Italy',
childIds: []
},
31: {
id: 31,
title: 'Portugal',
childIds: []
},
32: {
id: 32,
title: 'Spain',
childIds: []
},
33: {
id: 33,
title: 'Turkey',
childIds: []
},
34: {
id: 34,
title: 'Oceania',
childIds: [35, 36, 37, 38, 39, 40, 41],
},
35: {
id: 35,
title: 'Australia',
childIds: []
},
36: {
id: 36,
title: 'Bora Bora (French Polynesia)',
childIds: []
},
37: {
id: 37,
title: 'Easter Island (Chile)',
childIds: []
},
38: {
id: 38,
title: 'Fiji',
childIds: []
},
39: {
id: 39,
title: 'Hawaii (the USA)',
childIds: []
},
40: {
id: 40,
title: 'New Zealand',
childIds: []
},
41: {
id: 41,
title: 'Vanuatu',
childIds: []
},
42: {
id: 42,
title: 'Moon',
childIds: [43, 44, 45]
},
43: {
id: 43,
title: 'Rheita',
childIds: []
},
44: {
id: 44,
title: 'Piccolomini',
childIds: []
},
45: {
id: 45,
title: 'Tycho',
childIds: []
},
46: {
id: 46,
title: 'Mars',
childIds: [47, 48]
},
47: {
id: 47,
title: 'Corn Town',
childIds: []
},
48: {
id: 48,
title: 'Green Hill',
childIds: []
}
};
button { margin: 10px; }
상태를 중첩해서 사용해도 문법상 문제는 없지만, 데이터 구조를 "평탄하게" 만들면 수많은 잠재적 문제들을 예방할 수 있어요. 상태를 업데이트하기 훨씬 쉬워질 뿐만 아니라, 중첩된 객체의 다른 부분에 중복 데이터가 쌓이는 것을 막아주기도 한답니다.
가장 이상적인 처리는 메모리 사용량을 개선하기 위해 "테이블" 객체에서 삭제된 항목들(그리고 그 하위 자식 항목들까지!)을 완전히 지워버리는 것입니다. 이 버전은 바로 그 작업을 수행합니다. 게다가 업데이트 로직을 훨씬 간결하게 작성하기 위해 Immer 라이브러리를 활용했어요.
// src/App.js
import { useImmer } from 'use-immer';
import { initialTravelPlan } from './places.js';
export default function TravelPlan() {
const [plan, updatePlan] = useImmer(initialTravelPlan);
function handleComplete(parentId, childId) {
updatePlan(draft => {
// 부모 장소의 childIds 배열에서 삭제합니다.
const parent = draft[parentId];
parent.childIds = parent.childIds
.filter(id => id !== childId);
// 이 장소와 그 하위 서브트리 전체를 지워버립니다.
deleteAllChildren(childId);
function deleteAllChildren(id) {
const place = draft[id];
place.childIds.forEach(deleteAllChildren);
delete draft[id];
}
});
}
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
parentId={0}
placesById={plan}
onComplete={handleComplete}
/>
))}
</ol>
</>
);
}
function PlaceTree({ id, parentId, placesById, onComplete }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
<button onClick={() => {
onComplete(parentId, id);
}}>
Complete
</button>
{childIds.length > 0 &&
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
parentId={id}
placesById={placesById}
onComplete={onComplete}
/>
))}
</ol>
}
</li>
);
}
// src/places.js
export const initialTravelPlan = {
0: {
id: 0,
title: '(Root)',
childIds: [1, 42, 46],
},
1: {
id: 1,
title: 'Earth',
childIds: [2, 10, 19, 26, 34]
},
2: {
id: 2,
title: 'Africa',
childIds: [3, 4, 5, 6 , 7, 8, 9]
},
3: {
id: 3,
title: 'Botswana',
childIds: []
},
4: {
id: 4,
title: 'Egypt',
childIds: []
},
5: {
id: 5,
title: 'Kenya',
childIds: []
},
6: {
id: 6,
title: 'Madagascar',
childIds: []
},
7: {
id: 7,
title: 'Morocco',
childIds: []
},
8: {
id: 8,
title: 'Nigeria',
childIds: []
},
9: {
id: 9,
title: 'South Africa',
childIds: []
},
10: {
id: 10,
title: 'Americas',
childIds: [11, 12, 13, 14, 15, 16, 17, 18],
},
11: {
id: 11,
title: 'Argentina',
childIds: []
},
12: {
id: 12,
title: 'Brazil',
childIds: []
},
13: {
id: 13,
title: 'Barbados',
childIds: []
},
14: {
id: 14,
title: 'Canada',
childIds: []
},
15: {
id: 15,
title: 'Jamaica',
childIds: []
},
16: {
id: 16,
title: 'Mexico',
childIds: []
},
17: {
id: 17,
title: 'Trinidad and Tobago',
childIds: []
},
18: {
id: 18,
title: 'Venezuela',
childIds: []
},
19: {
id: 19,
title: 'Asia',
childIds: [20, 21, 22, 23, 24, 25,],
},
20: {
id: 20,
title: 'China',
childIds: []
},
21: {
id: 21,
title: 'India',
childIds: []
},
22: {
id: 22,
title: 'Singapore',
childIds: []
},
23: {
id: 23,
title: 'South Korea',
childIds: []
},
24: {
id: 24,
title: 'Thailand',
childIds: []
},
25: {
id: 25,
title: 'Vietnam',
childIds: []
},
26: {
id: 26,
title: 'Europe',
childIds: [27, 28, 29, 30, 31, 32, 33],
},
27: {
id: 27,
title: 'Croatia',
childIds: []
},
28: {
id: 28,
title: 'France',
childIds: []
},
29: {
id: 29,
title: 'Germany',
childIds: []
},
30: {
id: 30,
title: 'Italy',
childIds: []
},
31: {
id: 31,
title: 'Portugal',
childIds: []
},
32: {
id: 32,
title: 'Spain',
childIds: []
},
33: {
id: 33,
title: 'Turkey',
childIds: []
},
34: {
id: 34,
title: 'Oceania',
childIds: [35, 36, 37, 38, 39, 40, 41],
},
35: {
id: 35,
title: 'Australia',
childIds: []
},
36: {
id: 36,
title: 'Bora Bora (French Polynesia)',
childIds: []
},
37: {
id: 37,
title: 'Easter Island (Chile)',
childIds: []
},
38: {
id: 38,
title: 'Fiji',
childIds: []
},
39: {
id: 39,
title: 'Hawaii (the USA)',
childIds: []
},
40: {
id: 40,
title: 'New Zealand',
childIds: []
},
41: {
id: 41,
title: 'Vanuatu',
childIds: []
},
42: {
id: 42,
title: 'Moon',
childIds: [43, 44, 45]
},
43: {
id: 43,
title: 'Rheita',
childIds: []
},
44: {
id: 44,
title: 'Piccolomini',
childIds: []
},
45: {
id: 45,
title: 'Tycho',
childIds: []
},
46: {
id: 46,
title: 'Mars',
childIds: [47, 48]
},
47: {
id: 47,
title: 'Corn Town',
childIds: []
},
48: {
id: 48,
title: 'Green Hill',
childIds: []
}
};
button { margin: 10px; }
// 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"
}
}
가끔은 중첩된 상태의 일부를 하위 자식 컴포넌트로 이동시켜서 상태 중첩 깊이를 줄일 수도 있어요. 특정 아이템에 마우스를 올렸는지(hovered) 여부처럼, 굳이 영구적으로 저장할 필요가 없는 일시적인 UI 상태를 다룰 때 아주 좋은 방법입니다.
이 Clock 컴포넌트는 color와 time이라는 두 개의 prop을 받습니다. 여러분이 선택 박스(select box)에서 다른 색상을 고르면, Clock 컴포넌트는 부모 컴포넌트로부터 변경된 color prop을 전달받게 됩니다. 그런데 웬일인지 화면에 표시되는 색상은 업데이트되질 않네요. 대체 왜 그럴까요? 이 문제를 수정해 보세요.
// src/Clock.js
import { useState } from 'react';
export default function Clock(props) {
const [color, setColor] = useState(props.color);
return (
<h1 style={{ color: color }}>
{props.time}
</h1>
);
}
// src/App.js
import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
const [color, setColor] = useState('lightcoral');
return (
<div>
<p>
Pick a color:{' '}
<select value={color} onChange={e => setColor(e.target.value)}>
<option value="lightcoral">lightcoral</option>
<option value="midnightblue">midnightblue</option>
<option value="rebeccapurple">rebeccapurple</option>
</select>
</p>
<Clock color={color} time={time.toLocaleTimeString()} />
</div>
);
}
문제의 원인은 이 컴포넌트가 color prop의 초기값을 이용해 color 상태 변수를 한 번 초기화한 뒤로는, 부모로부터 받는 color prop이 변경되어도 상태 변수가 업데이트되지 않기 때문입니다. 결국 상태와 prop의 동기화가 어긋난 것이죠. 이 문제를 해결하려면, 혼란을 주는 상태 변수를 아예 지워버리고 부모가 전달해 주는 color prop을 직접 사용하면 됩니다!
// src/Clock.js
import { useState } from 'react';
export default function Clock(props) {
return (
<h1 style={{ color: props.color }}>
{props.time}
</h1>
);
}
// src/App.js
import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
const [color, setColor] = useState('lightcoral');
return (
<div>
<p>
Pick a color:{' '}
<select value={color} onChange={e => setColor(e.target.value)}>
<option value="lightcoral">lightcoral</option>
<option value="midnightblue">midnightblue</option>
<option value="rebeccapurple">rebeccapurple</option>
</select>
</p>
<Clock color={color} time={time.toLocaleTimeString()} />
</div>
);
}
혹은 구조 분해 할당(Destructuring) 문법을 사용하면 더 깔끔합니다:
// src/Clock.js
import { useState } from 'react';
export default function Clock({ color, time }) {
return (
<h1 style={{ color: color }}>
{time}
</h1>
);
}
// src/App.js
import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
const [color, setColor] = useState('lightcoral');
return (
<div>
<p>
Pick a color:{' '}
<select value={color} onChange={e => setColor(e.target.value)}>
<option value="lightcoral">lightcoral</option>
<option value="midnightblue">midnightblue</option>
<option value="rebeccapurple">rebeccapurple</option>
</select>
</p>
<Clock color={color} time={time.toLocaleTimeString()} />
</div>
);
}
이 짐 싸기 목록의 하단에는 '포장된 항목 수'와 '전체 항목 수'를 보여주는 푸터(footer)가 있습니다. 처음에는 잘 동작하는 것 같지만, 숨겨진 버그가 있어요. 예를 들어, 아이템을 포장됨(packed)으로 표시한 다음 그 아이템을 삭제해 보세요. 카운터가 올바르게 갱신되지 않을 겁니다. 항상 올바른 개수를 보여주도록 카운터를 수정해 보세요.
혹시 이 예제에 불필요한(redundant) 상태 변수가 숨어있는 건 아닐까요?
// src/App.js
import { useState } from 'react';
import AddItem from './AddItem.js';
import PackingList from './PackingList.js';
let nextId = 3;
const initialItems = [
{ id: 0, title: 'Warm socks', packed: true },
{ id: 1, title: 'Travel journal', packed: false },
{ id: 2, title: 'Watercolors', packed: false },
];
export default function TravelPlan() {
const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(3);
const [packed, setPacked] = useState(1);
function handleAddItem(title) {
setTotal(total + 1);
setItems([
...items,
{
id: nextId++,
title: title,
packed: false
}
]);
}
function handleChangeItem(nextItem) {
if (nextItem.packed) {
setPacked(packed + 1);
} else {
setPacked(packed - 1);
}
setItems(items.map(item => {
if (item.id === nextItem.id) {
return nextItem;
} else {
return item;
}
}));
}
function handleDeleteItem(itemId) {
setTotal(total - 1);
setItems(
items.filter(item => item.id !== itemId)
);
}
return (
<>
<AddItem
onAddItem={handleAddItem}
/>
<PackingList
items={items}
onChangeItem={handleChangeItem}
onDeleteItem={handleDeleteItem}
/>
<hr />
<b>{packed} out of {total} packed!</b>
</>
);
}
// src/AddItem.js
import { useState } from 'react';
export default function AddItem({ onAddItem }) {
const [title, setTitle] = useState('');
return (
<>
<input
placeholder="Add item"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<button onClick={() => {
setTitle('');
onAddItem(title);
}}>Add</button>
</>
)
}
// src/PackingList.js
import { useState } from 'react';
export default function PackingList({
items,
onChangeItem,
onDeleteItem
}) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.packed}
onChange={e => {
onChangeItem({
...item,
packed: e.target.checked
});
}}
/>
{' '}
{item.title}
</label>
<button onClick={() => onDeleteItem(item.id)}>
Delete
</button>
</li>
))}
</ul>
);
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
물론 여러분이 모든 이벤트 핸들러를 샅샅이 뒤져가며 total과 packed 카운터를 알맞게 업데이트하도록 코드를 끼워 맞출 수도 있겠지만, 근본적인 문제는 애초에 이런 상태 변수들이 존재한다는 것 자체입니다. 굳이 상태로 따로 저장하지 않아도, 현재 화면을 그리는 렌더링 시점에 items 배열의 정보를 바탕으로 항목의 개수(포장된 개수나 전체 개수)를 실시간으로 계산할 수 있으니까요! 불필요한 상태를 제거하면 버그는 자연스럽게 해결됩니다.
// src/App.js
import { useState } from 'react';
import AddItem from './AddItem.js';
import PackingList from './PackingList.js';
let nextId = 3;
const initialItems = [
{ id: 0, title: 'Warm socks', packed: true },
{ id: 1, title: 'Travel journal', packed: false },
{ id: 2, title: 'Watercolors', packed: false },
];
export default function TravelPlan() {
const [items, setItems] = useState(initialItems);
const total = items.length;
const packed = items
.filter(item => item.packed)
.length;
function handleAddItem(title) {
setItems([
...items,
{
id: nextId++,
title: title,
packed: false
}
]);
}
function handleChangeItem(nextItem) {
setItems(items.map(item => {
if (item.id === nextItem.id) {
return nextItem;
} else {
return item;
}
}));
}
function handleDeleteItem(itemId) {
setItems(
items.filter(item => item.id !== itemId)
);
}
return (
<>
<AddItem
onAddItem={handleAddItem}
/>
<PackingList
items={items}
onChangeItem={handleChangeItem}
onDeleteItem={handleDeleteItem}
/>
<hr />
<b>{packed} out of {total} packed!</b>
</>
);
}
// src/AddItem.js
import { useState } from 'react';
export default function AddItem({ onAddItem }) {
const [title, setTitle] = useState('');
return (
<>
<input
placeholder="Add item"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<button onClick={() => {
setTitle('');
onAddItem(title);
}}>Add</button>
</>
)
}
// src/PackingList.js
import { useState } from 'react';
export default function PackingList({
items,
onChangeItem,
onDeleteItem
}) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.packed}
onChange={e => {
onChangeItem({
...item,
packed: e.target.checked
});
}}
/>
{' '}
{item.title}
</label>
<button onClick={() => onDeleteItem(item.id)}>
Delete
</button>
</li>
))}
</ul>
);
}
button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }
코드를 수정하고 나니, 이제 이벤트 핸들러들은 오로지 setItems를 호출하는 일에만 집중할 수 있게 된 것을 확인하셨나요? 아이템의 개수 정보는 렌더링 될 때마다 items 배열로부터 새롭게 계산되므로, 앞으로는 절대로 실제 개수와 화면에 표시되는 개수가 어긋날 일이 없답니다.
여기 상태에 보관된 letters (편지) 리스트가 있습니다. 특정 편지에 마우스를 올리거나(hover) 포커스를 주면 해당 편지가 하이라이트 처리됩니다. 현재 하이라이트 된 편지 정보는 highlightedLetter라는 상태 변수에 저장되고요. 여러분은 각각의 편지를 "별표(Star)" 하거나 "별표 해제(Unstar)" 할 수 있는데, 버튼을 누르면 상태 안의 letters 배열 정보가 업데이트됩니다.
코드 동작 자체는 문제가 없지만, 사소한 UI 버그가 하나 있습니다. "Star" 또는 "Unstar" 버튼을 누르는 순간 하이라이트 효과가 잠깐 사라져 버려요! 다행히 포인터를 조금만 움직이거나 키보드로 다른 편지로 포커스를 옮기면 금세 다시 나타나긴 하지만요. 대체 왜 이런 현상이 일어나는 걸까요? 버튼을 클릭해도 하이라이트가 사라지지 않도록 코드를 고쳐 보세요.
// src/App.js
import { useState } from 'react';
import { initialLetters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [letters, setLetters] = useState(initialLetters);
const [highlightedLetter, setHighlightedLetter] = useState(null);
function handleHover(letter) {
setHighlightedLetter(letter);
}
function handleStar(starred) {
setLetters(letters.map(letter => {
if (letter.id === starred.id) {
return {
...letter,
isStarred: !letter.isStarred
};
} else {
return letter;
}
}));
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isHighlighted={
letter === highlightedLetter
}
onHover={handleHover}
onToggleStar={handleStar}
/>
))}
</ul>
</>
);
}
// src/Letter.js
export default function Letter({
letter,
isHighlighted,
onHover,
onToggleStar,
}) {
return (
<li
className={
isHighlighted ? 'highlighted' : ''
}
onFocus={() => {
onHover(letter);
}}
onPointerMove={() => {
onHover(letter);
}}
>
<button onClick={() => {
onToggleStar(letter);
}}>
{letter.isStarred ? 'Unstar' : 'Star'}
</button>
{letter.subject}
</li>
)
}
// src/data.js
export const initialLetters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];
button { margin: 5px; }
li { border-radius: 5px; }
.highlighted { background: #d2eaff; }
문제는 편지 객체 그 자체를 highlightedLetter 상태에 떡하니 담아두고 있었다는 점이에요. 게다가 그 똑같은 정보가 letters 배열 안에도 들어있고요. 상태가 중복되어 있는 거죠! 그래서 버튼을 눌러서 별표 상태를 바꿀 때, 코드가 letters 배열을 업데이트하면서 기존과는 다른 완전히 새로운 객체를 만들어 내게 됩니다. 당연히 새로 만들어진 이 객체는 highlightedLetter에 저장되어 있던 이전 객체와는 일치하지 않게 되죠. 그 결과 highlightedLetter === letter 조건 검사가 false를 뱉어내고 하이라이트가 증발해 버린 거예요. 이후 포인터가 다시 움직여 setHighlightedLetter가 새 객체를 잡아내면 그제야 하이라이트가 돌아오는 거고요.
이 문제를 우아하게 해결하려면, 상태의 중복을 걷어내면 됩니다. 편지 객체 그 자체를 두 곳에 저장하는 대신, 하이라이트 된 편지의 highlightedId만을 저장하도록 변경해 보세요. 그렇게 하면 각 편지마다 letter.id === highlightedId를 비교하게 되므로, 리렌더링 과정에서 객체가 새롭게 만들어져도 아무런 문제 없이 하이라이트가 제대로 유지됩니다.
// src/App.js
import { useState } from 'react';
import { initialLetters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [letters, setLetters] = useState(initialLetters);
const [highlightedId, setHighlightedId ] = useState(null);
function handleHover(letterId) {
setHighlightedId(letterId);
}
function handleStar(starredId) {
setLetters(letters.map(letter => {
if (letter.id === starredId) {
return {
...letter,
isStarred: !letter.isStarred
};
} else {
return letter;
}
}));
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isHighlighted={
letter.id === highlightedId
}
onHover={handleHover}
onToggleStar={handleStar}
/>
))}
</ul>
</>
);
}
// src/Letter.js
export default function Letter({
letter,
isHighlighted,
onHover,
onToggleStar,
}) {
return (
<li
className={
isHighlighted ? 'highlighted' : ''
}
onFocus={() => {
onHover(letter.id);
}}
onPointerMove={() => {
onHover(letter.id);
}}
>
<button onClick={() => {
onToggleStar(letter.id);
}}>
{letter.isStarred ? 'Unstar' : 'Star'}
</button>
{letter.subject}
</li>
)
}
// src/data.js
export const initialLetters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];
button { margin: 5px; }
li { border-radius: 5px; }
.highlighted { background: #d2eaff; }
이번 예제에서는 각 Letter 컴포넌트가 선택 여부를 나타내는 isSelected prop과 선택 상태를 반전시키는 onToggle 핸들러를 받고 있습니다. 코드는 잘 동작하긴 하지만, 현재 선택된 상태가 단 하나의 값을 갖는 selectedId(null 또는 ID)로 관리되고 있기 때문에 한 번에 편지 하나씩만 선택할 수 있습니다.
여러 개의 편지를 선택할 수 있도록 상태 구조를 변경해 보세요. (코드를 작성하기 전에, 나라면 어떤 구조로 만들지 한 번 곰곰이 생각해 보세요!) 체크박스들은 서로 독립적으로 동작해야 하고, 이미 선택된 편지를 다시 클릭하면 선택이 해제되어야 합니다. 그리고 맨 아래 푸터 영역에는 사용자가 선택한 편지의 개수가 올바르게 표시되어야겠죠.
선택된 ID 하나만 들고 있는 대신, 선택된 ID들의 배열이나 Set 객체를 상태로 유지해 보는 건 어떨까요?
// src/App.js
import { useState } from 'react';
import { letters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [selectedId, setSelectedId] = useState(null);
// TODO: 다중 선택을 허용하도록 코드를 변경하세요
const selectedCount = 1;
function handleToggle(toggledId) {
// TODO: 다중 선택을 허용하도록 코드를 변경하세요
setSelectedId(toggledId);
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isSelected={
// TODO: 다중 선택을 허용하도록 코드를 변경하세요
letter.id === selectedId
}
onToggle={handleToggle}
/>
))}
<hr />
<p>
<b>
You selected {selectedCount} letters
</b>
</p>
</ul>
</>
);
}
// src/Letter.js
export default function Letter({
letter,
onToggle,
isSelected,
}) {
return (
<li className={
isSelected ? 'selected' : ''
}>
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
onToggle(letter.id);
}}
/>
{letter.subject}
</label>
</li>
)
}
// src/data.js
export const letters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];
input { margin: 5px; }
li { border-radius: 5px; }
label { width: 100%; padding: 5px; display: inline-block; }
.selected { background: #d2eaff; }
단일 값을 담는 selectedId 대신에, 선택된 ID들의 목록을 들고 있는 selectedIds 배열을 상태로 사용하면 됩니다. 예를 들어 여러분이 첫 번째와 세 번째 편지를 선택했다면 배열 안에는 [0, 2]가 들어있게 되는 거죠. 아무것도 선택되지 않았을 때는 빈 배열 [] 상태가 됩니다:
// src/App.js
import { useState } from 'react';
import { letters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [selectedIds, setSelectedIds] = useState([]);
const selectedCount = selectedIds.length;
function handleToggle(toggledId) {
// 이전에 선택된 적이 있는 ID인가요?
if (selectedIds.includes(toggledId)) {
// 그렇다면 이 ID를 배열에서 쏙 빼서 새 배열을 만듭니다.
setSelectedIds(selectedIds.filter(id =>
id !== toggledId
));
} else {
// 아니라면, 이 ID를 배열에 추가합니다.
setSelectedIds([
...selectedIds,
toggledId
]);
}
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isSelected={
selectedIds.includes(letter.id)
}
onToggle={handleToggle}
/>
))}
<hr />
<p>
<b>
You selected {selectedCount} letters
</b>
</p>
</ul>
</>
);
}
// src/Letter.js
export default function Letter({
letter,
onToggle,
isSelected,
}) {
return (
<li className={
isSelected ? 'selected' : ''
}>
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
onToggle(letter.id);
}}
/>
{letter.subject}
</label>
</li>
)
}
// src/data.js
export const letters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];
input { margin: 5px; }
li { border-radius: 5px; }
label { width: 100%; padding: 5px; display: inline-block; }
.selected { background: #d2eaff; }
하지만 배열을 사용할 때 사소한 단점이 하나 있어요. 렌더링 할 때마다 각 아이템에 대해 selectedIds.includes(letter.id)를 호출해서 선택 여부를 확인하게 되는데, 만약 배열에 수만 개의 데이터가 들어있다면 퍼포먼스 이슈가 생길 수 있습니다. 왜냐하면 배열 안에서 값을 찾는 includes() 메서드는 배열 크기에 비례하는 선형 시간(O(n))이 걸리는데, 이걸 수많은 아이템 각각에 대해 반복하고 있기 때문이죠.
이런 성능 문제를 깔끔하게 해결하려면, 배열 대신 Set 객체를 상태로 유지하는 것이 좋습니다. Set이 제공하는 has() 메서드는 탐색 속도가 압도적으로 빠르거든요!
// src/App.js
import { useState } from 'react';
import { letters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [selectedIds, setSelectedIds] = useState(
new Set()
);
const selectedCount = selectedIds.size;
function handleToggle(toggledId) {
// (기존 상태를 직접 수정하는 것을 피하기 위해) 복사본을 만듭니다.
const nextIds = new Set(selectedIds);
if (nextIds.has(toggledId)) {
nextIds.delete(toggledId);
} else {
nextIds.add(toggledId);
}
setSelectedIds(nextIds);
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isSelected={
selectedIds.has(letter.id)
}
onToggle={handleToggle}
/>
))}
<hr />
<p>
<b>
You selected {selectedCount} letters
</b>
</p>
</ul>
</>
);
}
// src/Letter.js
export default function Letter({
letter,
onToggle,
isSelected,
}) {
return (
<li className={
isSelected ? 'selected' : ''
}>
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
onToggle(letter.id);
}}
/>
{letter.subject}
</label>
</li>
)
}
// src/data.js
export const letters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];
input { margin: 5px; }
li { border-radius: 5px; }
label { width: 100%; padding: 5px; display: inline-block; }
.selected { background: #d2eaff; }
자, 이제 각 아이템은 아주 빠른 속도의 selectedIds.has(letter.id) 검사를 거치게 됩니다.
마지막으로, 상태 안에 있는 객체는 직접 수정(mutate)해서는 안 된다는 사실을 명심하세요! 이것은 Set 객체에도 똑같이 적용됩니다. 그래서 handleToggle 함수 안에서 원본 Set을 직접 건드리지 않고, 새로운 Set의 복사본을 먼저 생성한 뒤에 그 복사본을 업데이트하는 방식으로 코드를 짠 것이랍니다.