상태 관리에서 의외로 자주 저지르는 실수는, 상태를 너무 많이 만드는 것이다. 특히 다른 상태에서 계산해 낼 수 있는 값까지 굳이 따로 useState로 들고 있는 경우다. 목록이 있으면 그 개수는 세면 나온다. 상품 목록이 있으면 총액은 더하면 나온다. 이렇게 이미 가진 상태에서 계산으로 얻어지는 값을 파생 상태(derived state)라고 부른다. 그리고 파생 상태는 대개 상태로 저장하면 안 된다.


계산해서 얻는 값은 상태가 아니다. 상태는 앱이 기억해야 할 원본 데이터다. 반면 그 원본에서 언제든 다시 뽑아낼 수 있는 값은 따로 기억할 필요가 없다. 필요할 때 그 자리에서 계산하면 된다. 장바구니를 예로 들면, 진짜 상태는 담긴 상품 목록 하나뿐이다. 개수도 총액도 목록만 있으면 나온다.


function Cart({ items }) {
const count = items.length;
const total = items.reduce((sum, it) => sum + it.price, 0);
return (
<div>
<p>{count}개 담김</p>
<p>합계 {total}원</p>
</div>
);
}


counttotaluseState가 아니다. 그냥 렌더될 때마다 items로부터 계산하는 보통 변수다. items가 바뀌면 컴포넌트가 다시 그려지고, 그때 이 계산도 다시 돌아 늘 최신 값이 나온다. 따로 관리할 게 없으니 어긋날 일도 없다.


상태로 중복 저장하면 어긋난다. 그런데 개수와 총액을 굳이 상태로 들고 있으면 어떻게 될까. 진실을 담은 원본이 둘, 셋으로 늘어난다. 그러면 하나를 바꿀 때 나머지도 손으로 맞춰줘야 하고, 한 군데라도 빠뜨리는 순간 화면이 서로 안 맞는다.


const [items, setItems] = useState([]);
const [count, setCount] = useState(0);

function add(item) {
setItems([...items, item]);
// setCount를 깜빡했다
}


상품은 목록에 잘 들어가는데 화면의 개수는 그대로다. setItems는 불렀지만 setCount를 깜빡했기 때문이다. 목록과 개수라는 두 개의 진실이 어긋난 것이다. 이런 버그는 에러도 안 나고 데이터도 반쯤 맞아 보여서 한참 못 찾는다. 나도 예전에 장바구니 숫자가 실제 담긴 개수와 자꾸 어긋나는 걸 두고, 갱신 함수를 어디서 빠뜨렸나 온 코드를 뒤진 적이 있다. 답은 애초에 개수를 상태로 둔 것 자체가 잘못이었다는 거였다. 개수는 상태가 아니라 items.length였어야 한다. 상태가 하나면 어긋날 짝이 없다.


화면에 걸러서 보여줄 목록도 계산이다. 검색창을 떠올려 보자. 사용자가 친 검색어에 맞는 항목만 목록에서 추려 보여준다. 여기서 초보가 흔히 저지르는 실수는, 걸러낸 결과 목록을 또 하나의 상태로 저장하는 것이다. 진짜 상태는 두 개면 된다. 원본 목록과 검색어다. 화면에 뿌릴 목록은 그 둘에서 계산해 낸다.


function SearchList({ items }) {
const [query, setQuery] = useState("");
const shown = items.filter((it) => it.name.includes(query));
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<p>{shown.length}건</p>
</div>
);
}


query는 사용자가 바꾸는 진짜 상태라 useState가 맞다. 반면 shownitemsquery에서 그때그때 걸러낸 파생 값이라 상태가 아니다. 만약 shown을 상태로 저장했다면, 검색어가 바뀔 때도 목록이 바뀔 때도 잊지 않고 다시 걸러 넣어줘야 한다. 둘 중 하나라도 놓치면 화면의 결과가 낡은 채로 남는다. 계산으로 두면 그런 동기화가 통째로 필요 없다.


계산이 무거우면 useMemo로 감싼다. 파생 값은 렌더마다 다시 계산된다고 했다. 대개는 그 비용이 미미해서 신경 쓸 필요가 없다. 하지만 수천 개짜리 목록을 정렬하고 걸러내는 무거운 계산이라면, 상관없는 상태가 바뀌어 리렌더될 때마다 그걸 매번 다시 도는 게 아깝다. 이럴 때 useMemo로 계산 결과를 기억해 둔다.


const visible = useMemo(() => {
return items
.filter((it) => it.price >= min)
.sort((a, b) => a.price - b.price);
}, [items, min]);


useMemo는 두 번째 인자로 준 의존성(items, min)이 바뀔 때만 안쪽 계산을 다시 돌린다. 그 외의 이유로 리렌더되면 지난번에 계산해 둔 값을 그대로 재사용한다. 다만 useMemo는 어디까지나 성능을 위한 최적화지, 파생 값을 만드는 필수 도구가 아니다. 계산이 가벼우면 그냥 렌더 중에 계산하는 게 낫다. 재보지도 않고 useMemo부터 두르는 건 오히려 코드만 복잡하게 만든다.


props를 상태로 복사하지 마라. 파생 상태의 사촌뻘 되는 함정이 하나 더 있다. 부모가 넘겨준 propsuseState의 초깃값으로 넣어두고 그걸 상태로 쓰는 것이다.


function Price({ price }) {
const [value, setValue] = useState(price);
return <span>{value}원</span>;
}


useState(price)의 초깃값은 첫 렌더 때 딱 한 번만 쓰인다. 그 뒤로 부모가 price를 새 값으로 바꿔 내려보내도, value는 처음 값에 붙박여 꿈쩍도 안 한다. 부모는 500원으로 바꿨는데 화면은 계속 300원인 것이다. 이 함정은 부모가 값을 바꾸기 전까지는 멀쩡히 돌아가서, 한참 뒤에야 증상이 드러나 원인을 짚기가 더 고약하다. 이것도 결국 하나면 될 진실을 둘로 쪼갠 탓이다. props로 받은 값을 그대로 보여줄 거면 상태로 복사하지 말고 price를 바로 쓰면 된다. 상태는 이 컴포넌트가 스스로 바꿔야 하는 값에만 쓴다.


헷갈리면 스스로 물어보라. 어떤 값을 상태로 둘지 파생으로 둘지 애매할 때 던지는 질문은 하나다. 이 값을 이미 가진 다른 상태만으로 언제든 다시 만들어 낼 수 있는가. 답이 그렇다면 그건 상태가 아니라 파생 값이니 렌더 중에 계산한다. 반대로 사용자가 직접 입력하거나 서버에서 받아온 값처럼 다른 무엇으로도 다시 만들 수 없는 것만 진짜 상태다. 이 질문 하나로 걸러내면 쓸데없이 늘어난 상태가 눈에 띄게 줄고, 서로 어긋나 생기던 버그도 같이 사라진다.


정리하면, 다른 상태에서 계산해 낼 수 있는 값은 상태로 저장하지 말고 렌더 중에 계산한다. 그래야 진실의 출처가 하나로 유지되어 값이 어긋나지 않는다. 계산이 정말 무거울 때만 useMemo로 결과를 아껴 쓰고, 부모가 준 props도 함부로 상태에 복사하지 않는다. 상태를 적게 두는 것, 꼭 필요한 것만 상태로 남기는 것이 상태 관리에서 가장 오래가는 요령이다.