8

useState를 남용하지 마세요

이 글은 TkDodoDon't over useState 포스트를 번역한 글입니다.

useState의 함정


useState는 React에서 제공하는 가장 기본적인 Hook으로 여겨지며 useEffect와 함께 가장 많이 사용되는 Hook 중 하나입니다.

Hook 자체는 간단하지만 state를 관리한다는 것은 쉽지 않기에 지난 몇 달 동안 잘못된 방식으로 사용되는 경우를 많이 봤습니다.

이 포스트는 제가 useState의 함정 이라고 부르는 시리즈의 첫 번째 파트이며 useState Hook을 사용하는 일반적인 시나리오를 보여주고 어떻게 이를 더 좋은 다른 방식으로 해결할 수 있는지 설명하려 합니다.

state란 무엇일까요?

모든 것은 state가 무엇인지 이해하는 것으로 귀결된다고 생각합니다. 더 정확하게는 무엇이 state가 아닌지 이해하는 것으로요! 이를 이해하기 위해서는 React 공식 문서에 적힌 내용만 제대로 이해하면 됩니다.

각 데이터에 대해 아래의 세 가지 질문을 통해 결정할 수 있습니다.
1. 부모로부터 props를 통해 전달됩니까? 그러면 확실히 state가 아닙니다.
2. 시간이 지나도 변하지 않나요? 그러면 확실히 state가 아닙니다.
3. 컴포넌트 안의 다른 state나 props를 가지고 계산 가능한가요? 그렇다면 state가 아닙니다.

props를 state에 할당하는 것(1)은 두 번째 파트에서 다룰 내용이고 setter를 사용하지 않는다면(2) state가 아니라는 사실은 명백합니다.

그러면 이제 세 번째 질문인 파생된 state만 남습니다. state로부터 계산된 값은 그 자체로 state가 아니라는 것은 매우 명백한 것처럼 보이지만 최근에 많은 코드를 리뷰했을 때 주니어, 시니어 관계없이 가장 많이 봤던 패턴입니다.

예시

원격 엔드 포인트로부터 데이터를 불러오고(카테고리 리스트) 사용자가 카테고리를 필터링할 수 있도록 하는 코드입니다.

state 관리가 되는 방식은 대부분의 경우 다음과 같았습니다.

import { fetchData } from "./api";
import { computeCategories } from "./utils";
const App = () => {
const [data, setData] = React.useState(null);
const [categories, setCategories] = React.useState([]);
React.useEffect(() => {
async function fetch() {
const response = await fetchData();
setData(response.data);
}
fetch();
}, []);
React.useEffect(() => {
if (data) {
setCategories(computeCategories(data));
}
}, [data]);
return <>...</>;
};

처음 이 코드를 본다면 아마 이렇게 생각할 겁니다. 데이터를 불러오는 effect가 있고 카테고리를 데이터와 동기화하는 effect가 있구나. 이게 정확히 useEffect Hook이 나온 이유(동기화를 시키는 것)인데 이 접근 방식에 무엇이 나쁜 거지?

동기화 해제

이 코드는 실제로 잘 동작할 것이고 사실 잘 읽히지 않거나 추론하기 어려운 것도 아닙니다. 문제는 setCategories 함수가 미래의 개발자에 의해 잘못된 방식으로 사용될 수 있다는 것입니다.

만약 카테고리가 오로지 데이터에만 의존하도록 하는 것이 의도였다면 이것은 문제가 될 수 있습니다.

import { fetchData } from "./api";
import { computeCategories, getMoreCategories } from "./utils";
const App = () => {
const [data, setData] = React.useState(null);
const [categories, setCategories] = React.useState([]);
React.useEffect(() => {
async function fetch() {
const response = await fetchData();
setData(response.data);
}
fetch();
}, []);
React.useEffect(() => {
if (data) {
setCategories(computeCategories(data));
}
}, [data]);
return (
<>
...
<Button onClick={() => setCategories(getMoreCategories())}>
Get more
</Button>
</>
);
};

그러면 이제 무엇이 카테고리인지 예측할 수 있는 방법이 없습니다.

  • 페이지가 로드되면 카테고리는 X
  • 사용자가 버튼을 클릭하면 카테고리는 Y
  • react-query를 사용할 경우 탭을 포커스 하거나 네트워크를 재연결하면 자동 재조회되는 기능에 의해 데이터가 재조회되고 카테고리는 X

이렇게 되면 매우 추적하기 어려운 버그를 만들어내게 되는 것입니다.

불필요한 state는 사용하지 마세요

사실 위 내용은 useState보다는 useEffect에 대한 잘못된 이해로부터 시작되었습니다. useEffect는 state를 react 외부의 무언가와 동기화시키기 위해서 사용되어야 하는데 이를 서로 다른 두 개의 react state와 동기화 시키는 데 이용하는 것은 거의 옳지 않습니다.

그래서 이렇게 말하고 싶습니다.

state 변경 함수가 effect 내부에서 오직 동기화를 위해서만 사용된다면 그 state를 제거하세요!
— TkDodo

이것은 @sophiebits가 최근 트위터에 올린 트윗과도 어느정도 관련이 있습니다.

여기에 첨언을 하자면 계산의 비용이 크지 않다면 useMemo를 통한 메모이제이션도 하지 않을 것입니다. 섣불리 최적화를 하려고 하지 말고 일단 측정을 먼저 하세요. 최적화를 하기 전에 무언가가 느리다는 증거가 있어야 합니다. 이 토픽에 대한 더 자세한 내용은 @ryanflorence의 아티클을 추천드립니다.

import { fetchData } from './api'
import { computeCategories } from './utils'
const App = () => {
const [data, setData] = React.useState(null) - const [categories, setCategories] = React.useState([]) + const categories = data ? computeCategories(data) : []
React.useEffect(() => { async function fetch() { const response = await fetchData() setData(response.data) }
fetch() }, [])
- React.useEffect(() => { - if (data) { - setCategories(computeCategories(data)) - } - }, [data])
return <>...</> }

effect의 사용을 절반으로 줄임으로써 복잡도를 낮췄고 이제 명백하게 카테고리는 데이터로부터 파생되었음을 확인할 수 있습니다. 만약 미래에 이 코드를 수정할 개발자가 카테고리를 다른 방식으로 계산하기를 원한다면 computeCategories 함수 내부를 수정하려고 할 것이며 그로 인해 우리는 항상 카테고리가 무언인지 어디로부터 왔는지 명백하게 알 수 있을 것입니다.

진실의 원천은 단 하나여야 합니다.