일본어로 된 리액트 공식 문서를 번역하면서 state를 state라고 그대로 썼는데, 역시 상태가 좀 더 익숙한 것 같아서 여기선 '상태'라고 쓰겠다 -_- ;;
쾌적하게 변경 또는 디버그가 가능한 컴포넌트와, 보통 버그의 원인이 되는 컴포넌트의 차이는, 상태를 잘 구조화할수있는지이다. 여기선, 상태 구조를 고려할 때 쓸모가 있는 몇 개의 힌트를 소개하겠다.
이 글에서 배울 수 있는 것
1. 단일 상태 변수와 복수 상태 변수의 쓰임새 차이
2. 상태를 구성할 때 피해야할 것
3. 상태 구조의 일반적인 문제와 수정 방법
상태 구조의 원리
상태는 쌓아둔 컴포넌트를 작성할 때, 상태를 몇 번 사용할 것인지, 데이터 구조를 어떻게 처리할지에 대한 선택을 할 필요가 있다. 최적이라곤 할 수 없는 상태 구조라도 올바른 프로그램을 작성하는 것은 가능하지만, 더 좋은 선택을 하기 위해 도움이 될만한 원리가 몇 가지 존재한다.
- 관련한 상태를 그룹화한다: 두 가지 이상의 상태 변수를 일반적으로 동시에 전송하려고 할 때, 그것들을 단일 상태 변수로 묶어 검토해보길 바란다.
- 상태의 모순을 피한다: 둘 이상의 상태 중 몇 개의 상태가 서로 모순되어 충돌할 경우, 실수가 발생할 여지가 있다는 것이다. 이것을 피해야 한다.
- 쓸데없이 긴 상태를 피한다: 컴포넌트의 props나 기존의 상태 변수에서 렌더링할 때 어떤 구조를 설계할 수 있다면, 그 구조를 컴포넌트의 상태에 꼭 집어넣어야 하는 것은 아니다.
- 상태 내부의 중복을 피한다: 같은 데이터가 여러가지의 상태 변수 사이, 어쩌면 네스팅되어있는 객체 사이에 중복이 있다면, 그것을 동기화시키는 것은 곤란하다. 가능한 한 중복을 줄여야한다.
- 깊은 단계의 상태를 피한다: 깊은 계층 구조로 되어있는 상태는 송신하기 쉽지 않다. 가능한 한, 상태를 같은 계층에 구조화하는 방법을 선택하도록 한다.
이와같은 원칙의 밑에 깔려있는 목표는, 실수가 끼어들지 않게 상태를 쉽게 갱신할 수 있도록 하는 것이다. 상태에서 쓸데없이 중복되는 데이터를 없애서, 모든 상태가 동기화 상태를 유지할 수 있게 된다. 이건, 데이터베이스 엔지니어가 버그를 줄이기 위해 데이터베이스 구조를 "정규화(normalize)"하려는 사고방식과 비슷하다. 알버트·아인슈타인의 흉내를 내보자면, 「상태는 되도록이면 간단하게 해야한다, 그러나 너무 간단해서도 안된다.」라는 거다.
이 원칙이 실제로 어떻게 적용되는지 보자.
관련된 상태를 그룹화하기
때로는, 단일 상태 변수를 사용할 지, 여러 상태 변수를 사용할지 고민되는 경우가 있을지도 모른다. 어떻게 해야할까?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
아니면 이렇게 해야할까?
const [position, setPosition] = useState({ x: 0, y: 0 });
기술적으로는, 어느 접근법으로 가도 괜찮다. 그러나 두 개의 상태 변수가 항상 같이 갱신되는 경우엔, 그것들을 단일 상태 변수로 묶는 것이 좋을 거다. 그렇게 하면, 언제나 둘 다 동시에 갱신하는 걸 잊을 일이 없다. 예를 들어 아래에, 커서를 움직이면 빨간 점 두 개의 축의 좌표가 갱신되는 예를 들겠다.
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>
)
}
상태를 객체 배열로 그룹화하는 다른 경우는, 상태의 수가 사전에 모르는 경우가 있다. 예를 들면, 사용자 커스텀 필드를 추가할 수 있는 폼이 있을 경우, 이것이 유용하다.
주의
상태 변수가 객체일 경우, 하나의 필드만을 변경하는 것은 불가능하고, 다른 필드도 명시적으로 복사할 필요가 있다는 걸 떠올려야 한다. 예를 들면, 상기의 예로setPosition({ x: 100 })
를 할 수는 없다.y
프로퍼티가 없기 때문이다! x 만을 설정하고 싶은 경우,setPosition({ ...position, x: 100 })
로 하던가, 두 개의 state 변수로 분할하여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>
</>
);
}
이 폼은 세 가지의 상태 변수가 존재한다. firstName
, lastName
, 그리고 fullName
이다. 그러나, fullName
은 불필요하다. 렌더링 중에 fullName
은 그저 firstName
과 lastName
으로부터 계산할 수 있기 때문에, 상태에서 삭제시키자.
그럼 아래와 같이 변한다.
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>
</>
);
}
이걸로 fullName은 상태 변수가 아니게 되었다. 대신, 렌더링 중에 계산된다:
const fullName = firstName + ' ' + lastName;
결과적으로, 이것을 갱신하기 위해 change 핸들러는 특별한 일을 할 필요가 사라졌다. setFirstName
이나 setLastName
을 호출하면, 재렌더링이 일어나서, 다음 fullName
은 새로운 데이터로부터 계산되고 값이 제대로 변한다.
props를 상태에 복사하고 싶을 때
불필요한 상태의 일반적인 예로, 이런 코드가 존재한다.
function Message({ messageColor }) { const [color, setColor] = useState(messageColor);
여기선, color라는 상태 변수가 props로 존재하는 messageColor의 값으로 초기화된다. 문제는, 새로운 컴포넌트가 후에 변하는 messageColor값(예를 들어 'blue'에서 'red')를 넘겨주었을 경우, 상태로 존재하는 color가 갱신되지 않는다는 것이다! 상태는 최초 렌더링 시에만 초기화된다.
이것이, props를 상태 변수에 '복사'하는 것이 혼란을 야기하는 이유다. 대신, messageColor를 코드의 직접사용하자. 짧은 이름으로 변경하고 싶을 경우엔, 상수를 사용하자.
function Message({ messageColor }) { const color = messageColor;
이것으로 더욱 더, 새로운 컴포넌트로부터 넘겨진 props와 동기화되지 않는 것을 막을 수 있다.
props를 상태에 '복사' 하는 것이 의미를 가지는 것은, 특정 props의 모든 갱신을 의도적으로 무시하고 싶은 경우뿐이다. 관습으로, 새로운 값이 와도 무시되는 것을 명확히 하고 싶을 경우, props의 이름을 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>
</>
);
}
현재, 선택한 항목을 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>
</>
);
}
어느 항목의 "Choose"를 클릭해서 편집하면, 입력란이 갱신되지만, 하단의 라벨은 편집내용을 반영하지 않는다. 이것은, 상태에 중복이 있어, selectedItem 쪽의 갱신을 잊어버렸기 때문이다.
selectedItem
쪽을 따로 갱신해주어도 좋지만, 간단한 해결책은 중복을 삭제하는 것이다. 이 예시에선, items
내의 객체와 selectedItem
객체를 중복시키는 대신, 상태에선 selectedId
를 보존하는 것으로 하고, 이 ID를 가진 아이템을 items
배열에서 검색하는 방법으로 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>
</>
);
}
이전의 상태가 이런 방식으로 중복되었다.
items = [{ id: 0, title: 'pretzels'}, ...]
selectedItem = {id: 0, title: 'pretzels'}
그러나, 변경 후는 아래와 같이 변한다.
items = [{ id: 0, title: 'pretzels'}, ...]
selectedItem = 0
중복이 사라지고, 필요한 상태만이 남는다!
이걸로, 선택된 항목을 편집하면, 아래의 메시지도 바로 갱신된다. 이걸로, setItems
가 재렌더링을 일으키고, items.find(...)
가 제목 갱신 후의 항목을 찾아내기 때문이다. 선택된 항목의 데이터 전체를 상태로 보관할 필요가 없었다. 왜냐하면 선태고딘 항목 ID만이 본질적이기 때문이다. 남은 것은 렌더링 시에 계산할 수 있다.
깊게 중첩된 상태 피하기
혹성, 대륙, 각 국마다 구성되는 여행 계획을 상상해보자. 아래와 같이, 상태를 집어넣은 객체와 배열을 다뤄 구조화하는 것이 매우 어려울지도 모른다.
// 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>
</>
);
}
// 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: []
...
여기서, 벌써 방문한 곳을 삭제하는 버튼을 추가하고 싶어졌다고 생각하자. 어떻게 하면 좋을까? 중첩된 상태를 갱신하면, 갱신된 부분의 모든 상위 객체의 복사본을 작성해야 할 필요가 생긴다. 깊게 중첩되어버린 장소 정보를 삭제하기 위해선, 부모로서 연결된 장소 데이터를 모두 복사할 필요가 있다. 이런 코드를 작성하는 건 매우 힘들다.
상태가 간단히 갱신되지 않으면 않을수록 중첩된 경우에는, '평면화'하는 것을 고려해보자. 여기선, 데이터를 재구축하는 방법의 하나를 가리킨다. place의 각각의 자식이 되는 장소 정보 그 자체를 배열로 가지는 것만이 아닌, 각각의 장소가 자식이 되는 장소 정보의 ID의 배열을 가지게 된다. 다음으로, 각각의 장소 ID와 대응하는 장소 정보의 매핑을 보존한다.
이와 같은 데이터 재구성을 보면, 데이터베이스 테이블을 떠올릴지도 모른다.
// 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>
</>
);
}
// 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]
},
...
상태가 '평면적'인 (다른 이름으로는 '정규화되었다(normalized)') 상태가 되었기 때문에, 중첩된 항목의 갱신이 단순해진다.
장소를 삭제하고 싶은 경우, 상태를 두 단계로 전달하는 것만으로 완료된다.
- 부모의 장소 정보를 갱신해,
childIds
배열에서, 삭제된 장소의 ID를 제외한다. - 루트의 '테이블' 객체를 갱신해, 상기의 갱신된 부모의 장소 정보를 포함하려고 한다.
아래는 실행 방법의 한 예이다.
// 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)
};
// Update the root state object...
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>
);
}
상태는 선호하는 만큼 중첩할 수 있지만, '평면화'하는 것으로 많은 문제를 해결할 수 있다. 상태의 갱신이 용이해질 뿐만 아니라, 중첩된 객체의 각각 부분에 중복이 없음을 보증하는 역할을 할 수도 있다.
메모리 사용량의 개선
기본적으론, 삭제된 장소 아이템(또는 그 자식 아이템) 자체도 '테이블' 객체로부터 삭제해서, 메모리 사용량을 개선하면 좋을 것이다. 아래의 버전은 그것을 행한 것이다. 또, 업데이트 로직을 보다 더 간결하게 하기 위해 Immer을 사용하고 있다. (*Immer: 복잡한 구조의 객체를 쉽고 편하게 불변성을 유지할 수 있도록 도와주는 라이브러리)
// 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 => { // 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]; } }); } // ... }
중첩된 상태의 일부를 자식 컴포넌트로 이동하여서, 상태의 중첩을 줄이는 것이 가능한 경우도 있다. 이것은 '아이템이 hover상태인지'와 같은, 보존할 필요가 없는 일시적인 UI 관련 상태에 적합하다.
정리
- 두 개의 상태 변수가 보통 한 번에 갱신되는 경우엔, 그것들을 하나로 합치는 것을 검토한다.
- '존재할 수 없는' 상태를 작성하지 않기 위해, 상태 변수를 주의 깊게 선택한다.
- 상태는, 갱신 시 실수가 발생하지 않는 쪽으로 구성한다.
- 불필요하고 중복되는 상태를 피하고, 상태 간 동기화가 요구될 가능성이 없도록 한다.
- 의도적으로 갱신되지 않게 하고싶은 경우를 제외하고, props를 상태로 복사하지 않는다.
- 항목 선택과 같은 UI 패턴에 대해선, 상태를 객체 자체가 아닌 ID 또는 인덱스로 보존한다.
- 깊게 중첩된 상태의 갱신이 복잡한 경우, 평면화를 시험해본다.