RSS듀오랩스
React

React state 구조 설계 원칙

작성자
듀오랩스 대표·9분 읽기

견적함 화면에 품목 목록과 합계가 있습니다.

function QuoteCart() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [selectedItem, setSelectedItem] = useState(null);

  function addItem(item) {
    setItems([...items, item]);
    setTotal(total + item.price * item.qty);
  }

  function changeQty(id, qty) {
    setItems(items.map((it) => (it.id === id ? { ...it, qty } : it)));
  }
  // ...
}

품목을 담으면 합계가 맞습니다. 수량을 바꾸면 합계가 그대로입니다. 선택해 둔 품목의 수량을 바꾸면 목록에는 새 수량이, 오른쪽 상세 패널에는 옛 수량이 보입니다. 버그는 세 곳인데, 원인은 한 가지입니다.

state 는 필요한 만큼 늘리면 된다는 생각

화면에 보여 줄 값이 생길 때마다 useState 를 하나씩 더하는 것은 자연스러운 흐름입니다. 합계를 보여 줘야 하니 total, 선택한 품목을 보여 줘야 하니 selectedItem 을 만듭니다. 각 state 는 자기 역할이 분명하고, 코드도 읽기 쉬워 보입니다.

이 방식으로 만든 state 들은 서로 독립적이지 않습니다. totalitems 에서 나온 값이고, selectedItemitems 안의 한 항목을 복사한 값입니다. 같은 사실을 여러 곳에 적어 두었으니, 한 곳을 고칠 때마다 나머지를 함께 고쳐야 합니다. 하나라도 빠뜨리면 화면이 서로 다른 말을 합니다.

Choosing the State Structure 문서는 state 를 고르는 원칙을 다섯 가지로 정리합니다. 관련된 state 묶기, 모순된 state 피하기, 중복된 state 피하기, 같은 데이터를 여러 곳에 두지 않기, 깊게 중첩하지 않기입니다. 이 글에서는 실무에서 버그가 가장 자주 나오는 세 가지를 앞의 견적함에 대입해 봅니다.

계산할 수 있는 값은 state 에 두지 않는다

문서의 문장입니다.

If you can calculate some information from the component's props or its existing state variables during rendering, you should not put that information into that component's state.

합계는 품목 목록에서 언제든 계산할 수 있습니다. 그런데 total 을 state 로 두었기 때문에, 품목을 담을 때는 합계를 고쳤지만 수량을 바꿀 때는 잊었습니다. 품목을 지우는 함수, 할인을 적용하는 함수가 생길 때마다 같은 실수를 할 기회가 늘어납니다.

const [items, setItems] = useState([]);
const total = items.reduce((sum, it) => sum + it.price * it.qty, 0);

이제 합계는 렌더링할 때마다 품목에서 계산됩니다. 품목을 바꾸는 함수가 몇 개가 되든 합계를 맞추는 코드는 한 줄도 필요 없습니다. 계산이 무겁다면 useMemo 로 감싸면 되지, state 로 옮길 이유가 되지 않습니다.

같은 원칙이 props 에도 적용됩니다. 문서는 props 를 state 에 복사하는 것(useState(initialColor))을 경계합니다. state 는 첫 렌더링에서만 초기화되므로, 부모가 나중에 다른 값을 넘겨도 state 는 옛 값에 머뭅니다. props 를 그대로 쓰면 이 문제가 없습니다.

객체를 복사하지 말고 id 를 기억한다

selectedItem 의 문제는 조금 다릅니다. 선택할 때 품목 객체를 통째로 state 에 복사했기 때문에, 같은 품목이 items 안에 하나, selectedItem 에 하나, 두 벌 존재합니다. 수량을 바꾸면 items 안의 것만 바뀝니다.

문서의 「같은 데이터를 여러 곳에 두지 않기」가 이 경우입니다. 해법은 객체 대신 id 를 기억하고, 필요할 때 목록에서 찾는 것입니다.

const [selectedId, setSelectedId] = useState(null);
const selectedItem = items.find((it) => it.id === selectedId);

선택이라는 사실은 id 하나면 충분합니다. 품목의 내용은 items 한 곳에만 있으므로, 목록과 상세 패널이 어긋날 수 없습니다. 선택한 품목이 삭제되면 findundefined 를 돌려주므로 「선택 없음」 처리도 자연스럽게 됩니다.

동시에 참일 수 없는 두 불리언

요청 버튼에도 흔한 구조가 있습니다.

const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

문서의 「모순된 state 피하기」가 드는 예와 같은 모양입니다. 두 불리언의 조합은 네 가지인데, 실제로 가능한 상태는 셋입니다. 보내기 전, 보내는 중, 보낸 뒤입니다. isSendingisSent 가 둘 다 true 인 조합은 말이 안 되는데, 어느 한 곳에서 setIsSending(false) 를 빠뜨리면 그 상태가 실제로 만들어집니다.

const [status, setStatus] = useState("typing"); // "typing" | "sending" | "sent"
const isSending = status === "sending";
const isSent = status === "sent";

state 를 하나로 합치면 불가능한 조합이 애초에 표현되지 않습니다. 불리언 두 개가 필요하면 status 에서 계산합니다. 앞 절의 「계산할 수 있는 값은 state 에 두지 않는다」가 여기서도 쓰입니다.

형제 컴포넌트가 같은 값을 쓸 때

state 를 어디에 둘지도 같은 원칙의 연장입니다. 견적함 목록과 요청서 미리보기가 둘 다 품목을 보여 준다면, 두 컴포넌트가 각자 items state 를 가지면 안 됩니다. 두 벌의 품목이 곧 어긋납니다.

Sharing State Between Components 문서는 이때 state 를 가장 가까운 공통 부모로 끌어올리고 props 로 내려 주라고 합니다. 문서는 이것을 「단일 출처(single source of truth)」라고 부르며, 모든 state 를 한곳에 모으라는 뜻이 아니라 각 state 마다 그 정보를 들고 있는 특정 컴포넌트가 정해져 있다는 뜻이라고 덧붙입니다.

state 마다 그 정보를 책임지는 컴포넌트가 하나 있으면 됩니다. 한 컴포넌트에서만 쓰는 state 는 그 컴포넌트에 두고, 여러 곳에서 쓰는 state 만 필요한 높이까지 올립니다. 필요 이상으로 올리면 리렌더링 글에서 본 것처럼 렌더링 범위만 넓어집니다.

state 를 줄이는 질문

제가 보기에 state 를 하나 만들기 전에 물을 질문은 셋입니다.

질문 그렇다면
다른 state 나 props 에서 계산할 수 있는가 state 가 아니라 렌더링 중 계산
이미 어딘가에 있는 데이터를 복사하는가 복사본 대신 id 를 기억
다른 state 와 동시에 참이 되면 안 되는 조합이 있는가 하나의 상태 값으로 합치기

세 질문을 통과한 state 만 남기면 state 의 수가 줄고, 줄어든 만큼 어긋날 수 있는 곳이 줄어듭니다. 문서가 이 절을 「가능한 한 단순하게, 그러나 더 단순하게는 말고」라는 인용으로 시작하는 것도 같은 취지라고 봅니다. 목표는 state 가 적은 것이 아니라, 같은 사실을 두 번 적지 않는 것입니다.

여기까지가 확실한 부분

state 구조의 다섯 원칙, 계산 가능한 값과 props 복사에 대한 경고, 모순된 불리언 예, 단일 출처의 정의는 react.dev 문서 기준입니다. 견적함 예시는 문서의 원칙을 설명하기 위해 만든 코드입니다. 깊게 중첩된 state 를 평평하게 펴는 방법은 문서에 따로 있지만 이 글에서는 다루지 않았습니다.

이 게시글 공유하기

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.