상태를 잘 구조화하는 것은 수정하고 디버깅하기 좋은 컴포넌트와 항상 버그의 원천이 되는 컴포넌트 간의 차이를 만들어요. 상태를 구조화할 때 고려해야할 몇가지 팁을 알려드릴게요.
이 페이지에서는
- 언제 한 개의 상태 변수를 사용하고 언제 여러개의 상태 변수를 사용하는지
- 상태를 정리할 때 무엇을 피해야하는지
- 상태 구조에서 자주 발생하는 이슈를 어떻게 해결하는지
를 알아볼 거예요.
Principles for structuring state | 상태 구조화의 원칙
여러개의 상태를 갖고 있는 컴포넌트를 작성할 때 몇개를 사용해야하는지, 그리고 데이터의 모양은 어떻게 되어야하는지를 선택해야해요. 최적이 아닌 상태 구조로도 올바른 프로그램을 작성할 수 있지만 더 나은 선택을 하도록 만들어주는 몇가지 원칙이 있어요.
- 연관된 상태를 하나로 묶으세요. 만약 언제나 두 개 이상의 상태 변수를 동시에 업데이트한다면, 이들을 하나의 상태 변수로 합치는 것을 고려해보세요.
- 상태에서 모순을 피하세요. 몇 개의 상태가 서로 모순되거나 서로에게 "적합하지" 않은 방법으로 구조화 되었다면 실수할 여지가 생겨요. 이를 피하세요.
- 불필요한 상태를 피하세요. 만약 컴포넌트의 props나 기존의 상태 변수에서 렌더링하는 동안 정보를 계산할 수 있다면 컴포넌트의 상태로 이 정보를 넣으면 안돼요.
- 중복 상태를 피하세요. 같은 데이터가 여러 상태에서 또는 중첩되는 객체 안에서 중복된다면 동기화시키는 것이 어려워요. 할 수 있다면 중복을 줄이세요.
- 깊게 중첩되는 상태를 피하세요. 깊은 계층을 가진 상태는 업데이트하기 불편해요. 가능하다면 평평하게 상태 구조를 구성하세요.
이러한 원칙의 목표를 _실수 없이 상태를 쉽게 업데이트하는 것_이에요. 불필요하고 중복되는 데이터를 상태에서 삭제하면 모든 상태는 동기화 되었다는 것을 보장할 수 있어요. 이는 버그가 발생할 여지를 줄이기 위해 데이터베이스 엔지니어가 데이터베이스 구조를 "정규화"하는 것과 비슷해요. 알버트 아인슈타인의 말을 인용하자면, "상태를 가능한 단순하게 만들되 너무 단순하게 만들지 마세요."
이제, 실제로 이 원칙을 어떻게 사용하는지를 볼게요.
Group related state | 연관된 상태 묶기
때때로 하나의 상태 변수 혹은 여러개의 상태 변수 중 어떤 것을 사용해야하는지 결정하지 못할 수도 있어요.
여러분은 이렇게 할 건가요?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
아니면 이렇게 할 건가요?
const [position, setPosition] = useState({ x: 0, y: 0 });
기술적으로는 이 두 방법을 모두 사용할 수 있어요. 하지만 만약 두 개의 상태가 항상 같이 변한다면 이들을 하나의 상태로 통합하는 것이 더 좋아요. 그리고 나서 아래의 예시처럼 항상 동기화하는 것을 잊지 마세요. 아래 예시에서는 커서를 움직이는 곳으로 붉은 점의 두 좌표가 업데이트 돼요.
데이터를 객체 또는 배열로 묶어야하는 또 다른 경우는 필요한 상태가 몇 개인지를 모르는 경우에요. 예를 들어, 사용자가 직접 필드를 추가할 수 있는 폼이 있을 때 도움이 될 거예요.
함정
만약 상태 변수가 객체라면 명시적으로 다른 필드를 복사하지 않고 하나의 필드만을 업데이트 할 수 없다는 사실을 기억하세요. 예를 들어,y
속성은 전혀 갖고 있지 않기 때문에 위의 예시에서setPosition({ x: 100 })
을 할 수 없어요. 대신, 만약x
만을 설정하고 싶다면setPosition({ ...position, x: 100 })
을 사용하거나 이 두 상태를 분리하여setX(100)
으로 작성해야해요.
Avoid contradictions in state | 상태에서 모순 피하기
이 코드는 isSending
과 isSent
라는 상태 변수를 가진 호텔 피드백 폼이에요.
이 코드는 잘 동작하지만 "불가능한" 상태가 발생할 여지가 있어요. 예를 들어서 만약 setIsSent
와 setIsSending
을 함꼐 보내는 것을 잊어버린다면 isSneding
과 isSent
가 둘 다 동시에 true
가 되는 상황이 발생할 거예요.
isSending
과 isSent
는 절대로 같이 true
가 되어서는 안되기 때문에 이들을 하나의 status
라는 상태 변수로 대체하여 typing
(초기값), sending
그리고 sent
의 3가지 유효한 상태 중 하나를 갖게 하는 것이 더 좋아요.
여전히 가독성을 위해 상수를 선언할 수 있어요.
const isSending = status === 'sending';
const isSent = status === 'sent';
그러나 이 상수들은 상태 변수가 아니기 때문에 서로 동기화되지 않아도 돼요.
Avoid redundant state | 불필요한 상태 피하기
만약 렌더링하는 동안 컴포넌트의 props나 기존의 상태 변수에서 정보를 계산할 수 있다면 컴포넌트의 상태로 해당 정보를 넣지 마세요.
그 예로 아래 폼을 보세요. 잘 동작하는 컴포넌트지만, 그 안에서 불필요한 상태를 찾을 수 있나요?
이 폼은 firstName
, lastName
, 그리고 fullName
이라는 세 개의 상태 변수를 가지고 있어요. 그러나 fullName
은 불필요해요. fullName
은 firstName
과 lastName
은 렌더링하는 동안 항상 계산할 수 있기 때문에 상태에서 제거할 수 있어요.
아래처럼 하면 돼요.
여기서 fullName
은 상태 변수가 아니에요. 대신 렌더링 동안 계산돼요.
const fullName = firstName + ' ' + lastName;
결론적으로 변경 핸들러는 이를 업데이트 하기 위해 특별한 무언가를 할 필요가 없어요. setFirstName
또는 setLastName
을 호출할 때, 리렌더링을 발생시킨 후에 다음 fullName
은 새로운 데이터를 계산할 거예요.
상태에서 props를 미러링하지 마세요.
더 알아보기불필요한 상태의 흔한 예시는 아래와 같은 코드에요.
function Message({ messageColor }) { const [color, setColor] = useState(messageColor);
여기서
color
상태 변수는messageColor
이라는 prop으로 초기화돼요. 문제는 만약 부모 컴포넌트가 다른messageColor
의 값을 나중에 전달한다면 (예를 들어'red'
에서'blue'
로)color
_상태 변수_는 업데이트가 되지 않아요.
이것이 바로 prop을 상태 변수에서 "미러링"하는 것은 혼란을 야기할 수 있는 이유에요. 대신,
messageColor
prop을 코드에서 직접적으로 사용하세요. 만약 더 짧은 이름을 주고 싶다면 상수를 사용하세요.
function Message({ messageColor }) { const color = messageColor; }
이러한 방법은 부모 컴포넌트에서 전달받은 prop과 동기화되지 않아요.
상태에서 prop을 "미러링"하는 것은 특정한 prop의 업데이트를 무시하고 싶을 때에만 사용할 수 있어요. 컨벤션에 의하면
initial
이나default
로 시작하는 prop 이름은 새로운 값을 무시한다는 것을 의미해요.
function Message({ initialColor }) { // `color` 상태 변수는 `initialColor`의 *첫 번째* 값을 갖고 있어요. // `initialColor`의 이후 변경 사항은 무시돼요. const [color, setColor] = useState(initialColor);
Avoid duplication in state | 상태의 중복 피하기
이 메뉴 목록 컴포넌트는 여러 개의 여행 간식 중 하나만을 선택하도록 해줘요.
현재 selectedItem
상태 변수 안의 객체로 선택된 항목을 저장하고 있어요. 그러나 이는 좋은 패턴이 아니에요. selectedItem
의 콘텐츠는 items
목록 안의 항목들 중 하나와 동일한 객체에요. 이는 즉, 항목 자체에 대한 정보가 두 곳에 중복되었다는 점을 의미해요.
왜 이런 일이 일어났을까요? 각각의 항목을 수정해볼게요.
항목에 있는 "Choose"를 먼저 누르고 나서 수정을 한다면 입력창은 업데이트 되지만 아래의 라벨은 수정사항을 반영하지 않는다는 점을 주목하세요. 이는 중복된 상태를 갖고 있고 selectedItem
을 업데이트하는 것을 잊어버렸기 때문에 발생하는 문제에요.
물론 selectedItem
을 업데이트할 수도 있음에도 불구하고 더 쉬운 방법은 중복을 제거하는 거예요. 이 예시에서 (items
안의 객체와 동일한) selectedItem
객체 대신 상태 안에서 selectedId
를 가지고 있고, 그 이후에 items
배열에서 ID로 아이템을 찾아내서 selectedItem
을 지정해요.
아래와 같이 상태는 원래 중복되어 있었어요.
items = [{ id: 0, title: 'pretzels'}, ...]
selectedItem = {id: 0, title: 'pretzels'}
그러나 바꾸고 난 후에는 아래와 같아요.
items = [{ id: 0, title: 'pretzels'}, ...]
selectedId = 0
중복은 사라지고 필수적인 상태만을 갖게 되었어요!
이제 만약에 선택된 항목을 수정하면 메시지는 즉시 변경될 거예요. 이것은 왜냐하면 setItems
는 리렌더링을 발생시키고 items.find(...)
은 업데이트된 제목으로 항목을 찾을 거예요. 여러분은 상태에서 선택된 아이템을 갖고 있을 필요가 없어요. 왜냐하면 _선택된 ID_만이 필수적이기 때문이에요. 나머지는 렌더링 동안 계산될 거예요.
Avoid deeply nested state | 깊게 중첩되는 상태 피하기
행성, 대륙, 국가를 포함한 여행 계획을 상상해보세요. 아래의 예시처럼 중첩된 객체나 배열을 사용하여 상태를 구조화할 수 있어요.
이제 이미 방문한 적이 있는 장소를 제거하는 버튼을 추가해볼게요. 어떻게 해야할까요? 중첩된 상태를 업데이트 하는 것은 변경된 곳부터 최상위까지의 모든 객체의 복사본을 만드는 것을 포함해요. 깊이 중첩된 곳을 지우는 것은 해당 장소의 전체 상위 장소를 전부 복사해야해요. 이러한 코드는 매우 구구절절해요.
만약 상태가 쉽게 업데이트 하기엔 너무 중첩되었다면 "평평하게" 만드는 것을 생각해보세요. 한 가지 방법은 이 데이터를 재구조화하는 거예요. 각 place
가 그것의 자식 공간들의 배열을 갖고 있는 트리와 같은 구조 대신에 자식 공간의 ID 배열을 가진 공간으로 만들 수 있어요. 그리고 나서 각 공간의 ID를 해당하는 공간과 매핑해요.
이런 데이터 재구조화는 데이터베이스 테이블처럼 보일 거예요.
상태는 이제 "평평"해졌고(이는 정규화로 알려져있어요.) 중첩된 항목들을 더 쉽게 업데이트할 수 있어요.
이제 공간을 지우기 위해서는 두 레벨의 상태를 업데이트해야해요.
- 부모 공간의 업데이트된 버전은
childIds
배열에서 제거된 ID를 제외해야해요. - 루트 "table" 객체의 업데이트된 버전은 부모 공간의 업데이트된 버전을 포함해야해요.
아래의 예시는 어떻게 이를 구현하는지 보여줘요.
원하는 만큼 상태를 중첩할 수는 있지만 이들을 "평평"하게 만들면 수많은 문제들을 해결할 수 있어요. 상태를 더 쉽게 업데이트할 수도 있고, 중첩된 객체의 다른 부분에서 중복을 없앨 수도 있어요.
메모리 사용성 향상시키기
더 알아보기이상적으로는 "table" 객체에서 메모리 사용성을 향상시키기 위해 제거된 항목들(과 그들의 자식들)도 제거되어야해요. 이 버전이 바로 제거한 버전이에요, 업데이트 로직을 더욱 간단하게 작성하려면 Immer를 사용할 수도 있어요.
때때로, 중첩된 상태 중 몇 개를 자식 컴포넌트로 이동시켜서 상태의 중첩을 줄일 수도 있어요. 이는 항목이 호버될 때 나타나는 UI와 같은, 상태를 저장할 필요가 없는 이퍼메럴(ephemeral, 임시) UI에서는 잘 작동해요.
Recap | 요약
- 두개의 상태 변수가 언제나 같이 업데이트된다면 이들을 하나로 합치는 것을 고려해보세요.
- "불가능한" 상태를 만들지 않기 위해 상태 변수를 신중하게 고르세요.
- 업데이트하면서 실수가 발생할 여지를 줄이는 방법으로 상태를 구조화하세요.
- 불필요하고 중복되는 상태를 피하여 동기화된 상태를 유지하세요.
- 업데이트를 특별히 막고 싶지 않다면 props를 상태 안에 넣지 마세요.
- 셀렉션 같은 UI 패턴에서는 상태에 객체 그 자체 대신 ID나 인덱스를 유지하세요.
- 만약 갚게 중천된 상태를 업데이트하는 것이 복잡하다면 평평하게 만드세요.
Challenges | 도전 과제
1. 업데이트하지 않은 컴포넌트 고치기
이 Clock
컴포넌트는 colors
와 time
이라는 두가지 props를 받아요. 셀렉트 박스에서 다른 색을 선택할 때 Clock
컴포넌트는 다른 color
prop을 그 부모 컴포넌트에서 받아요. 그러나 여러 이유로 보이는 색상은 바뀌지 않아요. 왜 그럴까요? 문제를 해결해보세요.
Solution
문제점은 이 컴포넌트가 color
prop으로 초기값이 설정된 color
상태를 갖고 있어서 발생했어요. 하지만 만약 color
prop이 변경된다면 이는 상태 변수에 영향을 미치지 않아요. 이 문제를 해결하기 위해 상태 변수를 모두 제거하고 color
prop을 직접 사용하세요.
또는 구조분해 문법을 사용하세요.
2. 망가진 짐 리스트 고치기
아래의 짐 리스트는 몇 개의 물건을 쌌는지 그리고 전체가 몇 개인지를 보여주는 footer를 갖고 있어요. 처음에는 작동을 하는 것처럼 보이지만 버그가 발생해요. 예를 들어 만약 어떤 물건을 이미 가방에 싼 물건으로 표시한 후 지운다면 카운터는 알맞게 업데이트되지 않아요. 카운터를 고쳐 항상 잘 동작하게 만드세요.
Hint
예시에 불필요한 상태는 없나요?
Solution
total
과 packed
카운터를 올바르게 업데이트하는 각각의 이벤트 핸들러를 신중하게 수정한다고 하더라도 근본적인 문제는 이 컴포넌트의 모든 상태 변수가 전부 남아있다는 거예요. items
배열 자체에서 항상 (이미 싼 또는 전체) 물건의 개수를 계산할 수 있기 떄문에 불필요해요. 버그를 고치려면 불필요한 상태를 모두 제거하세요.
변경 후에 이벤트 핸들러는 setItems
를 호출하는 데만 관심이 있다는 것을 주목하세요. 물건의 개수는 이제 다음 렌더링의 items
로 계산되기 떄문에 이들은 항상 최신 상태를 유지해요.
3. 사라진 셀렉션 고치기
상태에는 letters
의 목록이 있어요. 특정 편지를 호버하거나 포커싱하면 하이라이트돼요. 현재 하이라이트된 편지는 highlightedLetter
이라는 상태 변수에 저장돼요. 각각의 편지를 "stat"하거나 "unstar"할 수도 있고 이는 상태의 letters
배열을 업데이트해요.
이 코드는 동작하지만 작은 UI의 문제가 있어요. "Star"이나 "Unstar"을 누르면 하이라이팅이 잠시 사라져요. 그러나 포인터를 움직이거나 키보드를 사용해 다른 편지로 바꾸면 다시 나타나요. 왜 이런 일이 발생할까요? 하이라이팅이 버튼을 클릭한 후에는 사라지도록 고쳐보세요.
Solution
highlightedLetter
안에서 편지 객체를 갖고 있기 때문에 문제가 발생했어요. 그러나 letters
배열에 똑같은 정보를 갖고 있어요. 그래서 상태가 중복이 되었어요! letters
배열을 클릭 후에 업데이트하면 highlightedLetter
와는 다른 편지의 새로운 편지 객체가 만들어져요. 이는 highlightedLetter === letter
가 false
가 되는 이유이고 하이라이트가 사라지는 이유에요. 포인터가 이동하여 setHighlightedLetter
을 다시 호출하면 이는 다시 나타나요.
이 문제를 해결하기 위해 상태에서 중복을 제거하세요. letter
그 자체를 두 곳에 저장하는 대신 highlightedId
에 저장하세요. 그리고 나서 isHighlighted
를 각 편지에 letter.id === highlightedId
로 체크하면 letter
객체가 이전 렌더링 이후에 변화했더라도 잘 동작할 거예요.
4. 다중 셀렉션 구현하기
이번 예시에서는 각각의 Letter
는 isSelected
prop와 선택되었음을 표시하는 onToggle
핸들러를 갖고 있어요. 이 코드는 작동하지만 상태는 (null
또는 ID인) selectedId
로 저장되었기 때문에 하나의 편지만이 선택될 수 있어요.
다중 선택을 지원하기 위해 상태 구조를 변경하세요. (어떻게 구조화할 건가요? 코드를 치기 전에 생각해보세요.) 각각의 체크박스는 서로 독립적이에요. 선택된 편지를 클릭하면 선택이 해제되어야해요. 마지막으로 footer는 선택된 항목의 수를 올바르게 보여줘야해요.
Solution
하나의 selectedId
대신 selectedIds
라는 배열을 상태에서 갖고 있으세요. 예를 들어 만약 첫 번째와 마지막 문자를 골랐다면 [0, 2]
를 포함하세요. 아무것도 선택되지 않았다면 빈 배열이 될 거예요.
배열 사용의 한 가지 사소한 단점은 각각의 항목에서 선택되었는지를 체크한 selectedIds.includes(letter.id)
을 호출한다는 거예요. 만약 배열이 너무 크다면 includes()
로 배열 탐색을 하면 선형 시간이 필요하고 각각의 항목마다 이 탐색을 해야하기 때문에 성능 문제가 발생할 수 있어요.
이를 고치기 위해서는 상태에 집합을 넣을 수 있어요. 집합은 빠른 has()
연산을 제공해요.
이제 각각의 아이템은 selectedIds.has(letter.is)
를 체크하고 이는 훨씬 빨라요.
상태 안에서 객체를 변이하면 안된다는 사실은 집합도 마찬가지라는 점을 꼭 기억하세요. 이는 handleToggle
함수가 집합의 복사본을 먼저 생성하고나서 복사본을 업데이트하는 이유에요.
'리액트 공식문서 | React Docs > Learn > Learn React' 카테고리의 다른 글
[Managing State] Preserving and Resetting State | 상태 보존 및 초기화하기 (1) | 2024.02.27 |
---|---|
[Managing State] Sharing State Between Components | 컴포넌트 간 상태 공유하기 (0) | 2024.02.26 |
[Managing State] Reacting to Input with State | 상태로 입력창에 반응하기 (0) | 2024.02.22 |
[Managing State] Managing State Overview | 상태 관리하기 개요 (0) | 2024.02.22 |
[Adding Interactivity] Updating Arrays in State | 상태에서 배열 업데이트하기 (0) | 2024.02.21 |