| title | State 구조 선택하기 |
|---|
State를 잘 구조화하면 수정과 디버깅이 즐거운 컴포넌트와 지속적인 버그의 원인이 되는 컴포넌트의 차이를 만들 수 있습니다. 다음은 state를 구조화할 때 고려해야 할 몇 가지 팁입니다.
- 단일 vs 다중 state 변수를 사용하는 경우
- State를 구성할 때 피해야 할 사항
- 상태 구조의 일반적인 문제를 해결하는 방법
상태를 갖는 구성요소를 작성할 때, 사용할 state 변수의 수와 데이터의 형태를 선택해야 합니다. 최적이 아닌 state 구조에서도 올바른 프로그램을 작성할 수 있지만, 더 나은 선택을 할 수 있는 몇 가지 원칙이 있습니다.
- 연관된 state 그룹화하기. 두 개 이상의 state 변수를 항상 동시에 업데이트한다면, 단일 state 변수로 병합하는 것을 고려하세요.
- State의 모순 피하기. 여러 state 조각이 서로 모순되고 "불일치"할 수 있는 방식으로 state를 구성하는 것은 실수가 발생할 여지를 만듭니다. 이를 피하세요.
- 불필요한 state 피하기. 렌더링 중에 컴포넌트의 props나 기존 state 변수에서 일부 정보를 계산할 수 있다면, 컴포넌트의 state에 해당 정보를 넣지 않아야 합니다.
- State의 중복 피하기. 여러 상태 변수 간 또는 중첩된 객체 내에서 동일한 데이터가 중복될 경우 동기화를 유지하기가 어렵습니다. 가능하다면 중복을 줄이세요.
- 깊게 중첩된 state 피하기. 깊게 계층화된 state는 업데이트하기 쉽지 않습니다. 가능하면 state를 평탄한 방식으로 구성하는 것이 좋습니다.
이러한 원칙 뒤에 있는 목표는 오류 없이 상태를 쉽게 업데이트하는 것 입니다. State에서 불필요하고 중복된 데이터를 제거하면 모든 데이터 조각이 동기화 상태를 유지하는 데 도움이 됩니다. 이는 데이터베이스 엔지니어가 데이터베이스 구조를 "정규화"하여 버그 발생 가능성을 줄이는 것과 유사합니다. 알베르트 아인슈타인의 말을 빌리자면, "당신의 state를 가능한 한 단순하게 만들어야 한다, 더 단순하게 가 아니라."
이제 이 원칙들이 실제로 어떻게 적용되는지 살펴보겠습니다.
단일 state 변수와 다중 state 변수 사이에서 무엇을 사용할지 불확실한 경우가 있습니다.
이렇게 해야 할까요?
const [x, setX] = useState(0);
const [y, setY] = useState(0);아니면 이렇게?
const [position, setPosition] = useState({ x: 0, y: 0 });기술적으로 이 두 가지 접근 방식 모두 사용할 수 있습니다. 하지만 두 개의 state 변수가 항상 함께 변경된다면, 단일 state 변수로 통합하는 것이 좋습니다. 그러면 마우스 커서를 움직이면 빨간 점의 두 좌표가 모두 업데이트되는 이 예시처럼 항상 동기화를 유지하는 것을 잊지 않을 것입니다.
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; }데이터를 객체나 배열로 그룹화하는 또 다른 경우는 필요한 state의 조각 수를 모를 때입니다. 예를 들어, 사용자가 커스텀 필드를 추가할 수 있는 양식이 있는 경우에 유용합니다.
State 변수가 객체인 경우에는 다른 필드를 명시적으로 복사하지 않고 하나의 필드만 업데이트할 수 없다는 것을 기억하세요. 예를 들어 위의 예시에서 setPosition({ x: 100 })은 y 속성이 존재하지 않기 때문에 사용할 수 없습니다! 대신, x만 설정하려면 setPosition({ ...position, x: 100 })을 하거나 두 개의 state 변수로 나누고 setX(100)을 해야 합니다.
다음은 isSending과 isSent state 변수가 있는 호텔 피드백 양식입니다.
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);
});
}이 코드는 작동하긴 하지만, "불가능한" state를 허용합니다. 예를 들어 setIsSent와 setIsSending을 함께 호출하는 것을 잊어버린 경우, isSending과 isSent가 동시에 true인 상황에 처할 수 있습니다. 컴포넌트가 복잡할수록 무슨 일이 일어났는지 이해하기가 어렵습니다.
isSending과 isSent는 동시에 true가 되어서는 안되기 때문에, 이 두 변수를 'typing'(초깃값), 'sending', 'sent' 세 가지 유효한 상태 중 하나를 가질 수 있는 status state 변수로 대체하는 것이 좋습니다.
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';이들은 state 변수가 아니기 때문에 서로 동기화되지 않을 우려는 없습니다.
렌더링 중에 컴포넌트의 props나 기존 state 변수에서 일부 정보를 계산할 수 있다면, 컴포넌트의 state에 해당 정보를 넣지 않아야 합니다.
예를 들어, 이 양식을 사용해 보세요. 작동은 하지만, 불필요한 state가 있지 않나요?
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의 세 가지 state 변수가 있습니다. 그러나 fullName은 불필요합니다. 렌더링 중에 항상 firstName과 lastName에서 fullName을 계산할 수 있기 때문에 state에서 제거하세요.
이렇게 하면 됩니다.
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은 state 변수가 아닙니다. 대신 렌더링 중에 계산됩니다.
const fullName = firstName + ' ' + lastName;따라서 변경 핸들러는 이를 업데이트하기 위해 특별한 작업을 수행할 필요가 없습니다. setFirstName 또는 setLastName을 호출하면, 다시 렌더링하는 것을 유발하여, 다음 fullName이 새 데이터로 계산됩니다.
다음 코드는 불필요한 state의 일반적인 예입니다.
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);여기서 color state 변수는 messageColor prop로 초기화됩니다. 문제는 부모 컴포넌트가 나중에 다른 값의 messageColor를 전달한다면 (예를 들어, 'blue' 대신 'red'), color state 변수 가 업데이트되지 않습니다! State는 첫 번째 렌더링 중에만 초기화됩니다.
그 때문에 state 변수의 일부 prop를 "미러링"하면 혼란이 발생할 수 있습니다. 대신 코드에 messageColor prop를 직접 사용하세요. 더 짧은 이름을 지정하려면 상수를 사용하세요.
function Message({ messageColor }) {
const color = messageColor;이렇게 하면 부모 컴포넌트에서 전달된 prop와 동기화를 잃지 않습니다.
Props를 상태로 "미러링"하는 것은 특정 prop에 대한 모든 업데이트를 무시하기를 원할 때에만 의미가 있습니다. 관례에 따라 prop의 이름을 initial 또는 default로 시작하여 새로운 값이 무시됨을 명확히 하세요.
function Message({ initialColor }) {
// The `color` state variable holds the *first* value of `initialColor`.
// Further changes to the `initialColor` prop are ignored.
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 state 변수에 객체로 저장합니다. 그러나 이는 좋지 않습니다. 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"를 클릭한 후 이를 편집할 경우, 입력이 업데이트되지만, 하단의 라벨에는 편집 내용이 반영되지 않습니다. 이는 state가 중복되었으며 selectedItem을 업데이트하는 것을 잊어버렸기 때문입니다.
selectedItem도 업데이트할 수 있지만 더 쉬운 수정 방법은 중복을 제거하는 것입니다. 이 예에서는 selectedItem 객체(items 내부의 객체와 중복을 생성하는) 대신 selectedId를 state로 유지하고, 그다음 items 배열에서 해당 ID의 항목을 검색하여 selectedItem을 가져옵니다.
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; }State는 다음과 같이 중복되었습니다.
items = [{ id: 0, title: 'pretzels'}, ...]selectedItem = {id: 0, title: 'pretzels'}
하지만 변경 후에는 다음과 같습니다.
items = [{ id: 0, title: 'pretzels'}, ...]selectedId = 0
중복은 사라지고 필수적인 state만 유지됩니다!
이제 선택한 항목을 편집하면 아래 메시지가 즉시 업데이트됩니다. 이는 setItems가 다시 렌더링하도록 유발하고, items.find(...)가 업데이트된 제목의 항목을 찾을 것이기 때문입니다. 선택한 ID만 필수이므로 선택한 항목을 state로 유지할 필요가 없습니다. 나머지는 렌더링하는 동안 계산할 수 있습니다.
행성, 대륙, 국가로 구성된 여행 계획을 상상해 보세요. 이 예시처럼 중첩된 객체와 배열을 사용하여 여행 계획의 state를 구성하고 싶을 수 있습니다.
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>
</>
);
}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: []
}]
}]
};이제 방문한 장소를 삭제하는 버튼을 추가하고 싶습니다. 어떻게 해야 할까요? 중첩된 state를 업데이트하는 것은 변경된 부분부터 모든 객체의 복사본을 만드는 것을 의미합니다. 깊게 중첩된 장소를 삭제하는 것은 전체 부모 장소 체인을 복사하는 것을 의미합니다. 이러한 코드는 매우 장황할 수 있습니다.
만일 state가 쉽게 업데이트하기에 너무 중첩되어 있다면, "평탄"하게 만드는 것을 고려하세요. 여기 데이터를 다시 구조화하는 한 가지 방법이 있습니다. 각 place가 자식 장소의 배열을 가지는 트리 구조 대신, 각 장소가 자식 장소 ID의 배열을 가지도록 할 수 있습니다. 그런 다음 각 장소 ID와 해당 장소에 대한 매핑을 저장하세요.
이 데이터 재구성은 데이터베이스 테이블을 떠올리게 할 수 있습니다.
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>
</>
);
}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: []
}
};이제 state가 "평탄"("정규화"라고도 함)하므로 중첩된 항목을 업데이트하는 것이 더 쉬워졌습니다.
이제 장소를 제거하기 위해, state의 두 단계만 업데이트하면 됩니다.
- 업데이트된 버전의 부모 장소는
childIds배열에서 제거된 ID를 제외해야 합니다. - 업데이트된 버전의 루트 "테이블" 객체는 부모 장소의 업데이트된 버전을 포함해야 합니다.
다음은 이를 수행하는 방법의 예입니다.
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];
// Create a new version of the parent place
// that doesn't include this child ID.
const nextParent = {
...parent,
childIds: parent.childIds
.filter(id => id !== childId)
};
// Update the root state object...
setPlan({
...plan,
// ...so that it has the updated parent.
[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>
);
}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; }State를 원하는 만큼 중첩할 수 있지만, "평탄"하게 만드는 것은 많은 문제를 해결할 수 있습니다. State를 업데이트하기 쉽게 만들고 중첩된 객체의 다른 부분에 중복이 없도록 도와줍니다.
이상적으로 메모리 사용량을 개선하기 위해서는 삭제된 항목(그리고 그들의 자식들!)을 "테이블" 객체에서 제거해야 합니다. 이 버전은 그렇게 합니다. 또한 업데이트 로직을 더 간결하게 만들기 위해 Immer를 사용합니다.
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 => {
// Remove from the parent place's child IDs.
const parent = draft[parentId];
parent.childIds = parent.childIds
.filter(id => id !== childId);
// Forget this place and all its subtree.
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>
);
}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',
<<<<<<< HEAD
childIds: [35, 36, 37, 38, 39, 40, 41],
=======
childIds: [35, 36, 37, 38, 39, 40, 41],
>>>>>>> d271a7ac11d2bf0d6e95ebdfacaf1038421f9be0
},
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; }{
"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"
}
}때로는 중첩된 state를 자식 컴포넌트로 이동시켜 state 중첩을 줄일 수도 있습니다. 이는 항목이 호버되었는가와 같이 저장할 필요가 없는 임시의 UI state에 대해 잘 작동합니다.
- 만약 두 state 변수가 항상 함께 업데이트된다면, 하나로 합치는 것을 고려해 보세요.
- State 변수를 신중하게 선택하여 "불가능한" state를 만들지 않도록 하세요.
- State를 업데이트할 때 실수할 가능성을 줄이도록 state를 구조화하세요.
- 동기화를 유지하지 않아도 되도록 불필요하고 중복된 state를 피하세요.
- 특별히 업데이트를 방지하려는 경우를 제외하고는 props를 state에 넣지 마세요.
- 선택과 같은 UI 패턴의 경우, 객체 자체가 아닌 ID 또는 인덱스를 state에 유지하세요.
- 깊게 중첩된 state를 업데이트하는 것이 복잡한 경우, 평탄하게 만들어 보세요.
이 Clock 컴포넌트는 color와 time 두 가지 props를 받습니다. 선택 창에서 다른 색상을 선택하면 Clock 컴포넌트는 부모 컴포넌트에서 다른 color prop을 받습니다. 그러나 어떤 이유에서인지 표시된 색상이 업데이트되지 않습니다. 왜 그럴까요? 문제를 해결하세요.
import { useState } from 'react';
export default function Clock(props) {
const [color, setColor] = useState(props.color);
return (
<h1 style={{ color: color }}>
{props.time}
</h1>
);
}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 state를 갖는 것입니다. 그러나 color prop가 변경되면 이는 state 변수에 영향을 주지 않습니다! 그래서 그들은 동기화되지 않습니다. 이 문제를 해결하기 위해, state 변수를 완전히 제거하고 color prop를 직접 사용하세요.
import { useState } from 'react';
export default function Clock(props) {
return (
<h1 style={{ color: props.color }}>
{props.time}
</h1>
);
}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>
);
}또는 구조 분해 구문을 사용하세요.
import { useState } from 'react';
export default function Clock({ color, time }) {
return (
<h1 style={{ color: color }}>
{time}
</h1>
);
}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>
);
}이 포장 목록에는 몇 개의 항목이 포장되었는지와 전체 항목 수를 보여주는 푸터가 있습니다. 처음에는 작동하는 것처럼 보이지만 버그가 있습니다. 예를 들어, 항목을 포장했다고 표시했다가 삭제하면 카운터가 올바르게 업데이트되지 않습니다. 항상 올바르게 작동하도록 카운터를 수정하세요.
이 예시에 불필요한 state가 있나요?
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>
</>
);
}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>
</>
)
}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 카운터를 올바르게 업데이트하도록 각 이벤트 핸들러를 신중하게 변경할 수 있지만, 근본적인 문제는 이 state 변수들이 존재한다는 것입니다. items 배열 자체에서 항목 수(포장된 항목 또는 전체)를 항상 계산할 수 있기 때문에 이들은 불필요합니다. 불필요한 state를 제거하여 버그를 수정하세요.
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>
</>
);
}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>
</>
)
}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에서 다음 렌더링하는 동안 계산되므로 항상 최신 상태입니다.
State에 letters 목록이 있습니다. 특정 문자에 호버 또는 포커스하면 하이라이트 됩니다. 현재 하이라이트 된 문자는 highlightedLetter state 변수에 저장됩니다. 각각의 문자에 "별표"와 "별표 해제"를 할 수 있으며, 이는 state의 letters 배열을 업데이트합니다.
이 코드는 작동하지만, 작은 UI 버그가 있습니다. "별표" 또는 "별표 해제"를 누르면 하이라이트가 잠시 사라집니다. 그러나 포인터를 움직이거나 키보드로 다른 문자로 전환하면 바로 다시 나타납니다. 왜 이런 일이 발생할까요? 버튼 클릭 후 하이라이트가 사라지지 않도록 수정하세요.
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>
</>
);
}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>
)
}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 배열에서도 동일한 정보를 보관하고 있습니다. 그래서 state에 중복이 있습니다! 버튼 클릭 후 letters 배열을 업데이트하면 highlightedLetter와 다른 새 문자 객체가 생성됩니다. 이것이 highlightedLetter === letter 검사가 false가 되고 하이라이트가 사라지는 이유입니다. 포인터가 움직일 때 setHighlightedLetter를 호출하면 다시 나타납니다.
문제를 해결하기 위해 state에서 중복을 제거하세요. 두 곳에 문자 자체 를 저장하는 대신 highlightedId를 저장하세요. 그런 다음 letter.id === highlightedId로 각 문자에 대해 isHighlighted를 확인할 수 있으며, 이는 마지막 렌더링 이후 letter 객체가 변경되었더라도 작동합니다.
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>
</>
);
}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>
)
}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 핸들러를 갖고 있습니다. 이는 작동하지만 state는 selectedId (null 또는 ID)로 저장되므로 한 번에 하나의 문자만 선택할 수 있습니다.
다중 선택을 지원하도록 state 구조를 변경하세요. (어떻게 구조화할까요? 코드를 작성하기 전에 이에 대해 생각해 보세요.) 각 체크박스는 다른 체크박스와 독립적이어야 합니다. 선택된 문자를 클릭하면 선택이 해제되어야 합니다. 마지막으로, 푸터는 선택된 항목의 올바른 수를 보여야 합니다.
하나의 선택된 ID 대신 선택된 ID의 배열 또는 Set을 state에 보관할 수 있습니다.
import { useState } from 'react';
import { letters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [selectedId, setSelectedId] = useState(null);
// TODO: allow multiple selection
const selectedCount = 1;
function handleToggle(toggledId) {
// TODO: allow multiple selection
setSelectedId(toggledId);
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isSelected={
// TODO: allow multiple selection
letter.id === selectedId
}
onToggle={handleToggle}
/>
))}
<hr />
<p>
<b>
You selected {selectedCount} letters
</b>
</p>
</ul>
</>
);
}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>
)
}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 대신 selectedIds 배열 을 state에 유지하세요. 예를 들어, 첫 번째와 마지막 문자를 선택하면 [0, 2]를 포함합니다. 아무것도 선택되지 않은 경우 빈 [] 배열이 됩니다.
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) {
// Was it previously selected?
if (selectedIds.includes(toggledId)) {
// Then remove this ID from the array.
setSelectedIds(selectedIds.filter(id =>
id !== toggledId
));
} else {
// Otherwise, add this ID to the array.
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>
</>
);
}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>
)
}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()를 사용한 배열 검색은 선형 시간이 걸리고, 개별 항목마다 검색을 수행하기 때문에 성능상 문제가 될 수 있습니다.
이를 해결하기 위해, state에 빠른 has() 연산을 제공하는 Set을 대신 보관할 수 있습니다.
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) {
// Create a copy (to avoid mutation).
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>
</>
);
}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>
)
}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) 검사를 수행합니다.
State의 객체를 변경해서는 안 되며, Set도 마찬가지입니다. 이것이 handleToggle 함수가 먼저 Set의 복사본 을 만들고 그 복사본을 업데이트하는 이유입니다.