거의 모든 React 코드베이스를 열어보면 똑같은 습관을 발견할 수 있습니다. 개발자는 값을 추적해야 할 때 useState를 찾습니다. 카운터가 필요하다고요? useState. 임시 입력값이 필요하다고요? useState. 모달을 켜고 끌 불리언 값이 필요하다고요? useState. 얼마 지나지 않아, 하나의 컴포넌트는 렌더링 간에 유지될 필요가 있을 수도, 없을 수도 있는 아주 작은 데이터 조각들을 관리하는 수십 개의 개별 훅을 가지게 됩니다. 그 결과 코드는 지저분해지고, 불필요한 리렌더링이 발생하며, 상태는 컴포넌트 여기저기에 잔돈처럼 흩어지게 됩니다.
이런 습관은 이해할 수 있습니다. useState는 우리 대부분이 가장 먼저 배우는 훅이고, 실제로 작동하니까요. 하지만 '작동한다'는 것이 '적합하다'는 것과 같지는 않습니다. 모든 데이터를 반응형 상태(reactive state)로 취급하면, 컴포넌트가 커졌을 때 비로소 나타나는 문제들이 발생합니다.
값이 변한다고 해서 반드시 상태(State)가 필요한 것은 아닙니다
시간이 지남에 따라 변하는 모든 변수가 useState에 들어가야 하는 것은 아닙니다. 어떤 값들은 이미 가지고 있는 다른 값의 결과물일 뿐입니다. 단순히 firstName과 lastName을 합친다는 이유로 사용자의 전체 이름을 상태에 저장한다면, 이제 데이터 소스가 두 개가 됩니다. 부모 컴포넌트가 리렌더링되어 firstName이 업데이트될 때, fullName 상태는 별도의 동기화 이펙트를 실행하기 전까지 오래된(stale) 상태로 남아 있게 됩니다. 동기화 이펙트는 필요하지 않습니다. 여러분에게 필요한 것은 파생된 값(derived value)입니다.
const fullName = `${firstName} ${lastName}`;
렌더링 중에 계산하세요. 계산 비용이 많이 든다면 메모이제이션(memoize)하세요. 하지만 사용자가 구성 요소와 독립적으로 그 전체 이름을 수정할 수 있는 경우가 아니라면, 별도의 useState 훅을 부여하지 마세요.
필터링된 리스트에도 동일한 규칙이 적용됩니다. allItems와 filteredItems를 모두 상태로 가지고 있다면, 유지보수해야 할 영역이 두 배로 늘어난 것입니다. 렌더링 중에 필터링하세요. 원본 배열과 필터 텍스트만 상태로 유지하고, 보여줄 리스트는 파생시키세요. 이렇게 하면 필터링된 리스트가 원본과 어긋나는 일이 절대 발생하지 않습니다.
어떤 값들은 절대로 리렌더링을 유발해서는 안 됩니다
useState는 무언가 변경되었고 DOM 업데이트가 필요할 수 있다는 것을 React에 알리기 위해 존재합니다. 값이 변경되더라도 UI의 어떤 부분도 그 변경에 관심이 없다면, useRef가 더 나은 도구입니다.
타이머와 인터벌이 대표적인 예입니다. setInterval ID를 상태에 저장하면, 사용자가 인터벌 ID를 볼 수 없음에도 불구하고 타이머를 시작하거나 멈출 때마다 리렌더링이 발생합니다. ref는 React에 알리지 않고도 해당 값을 유지할 수 있습니다. 이전 props를 추적하거나, 화면에 그려지기(paint) 전에 DOM 노드를 측정하거나, 커스텀 훅을 위한 최신 콜백을 저장하는 경우에도 동일한 논리가 적용됩니다. 스스로에게 물어보세요. "이 값이 화면에 나타나야 하는가?" 만약 대답이 "아니오"라면, 아마도 useState가 필요하지 않을 것입니다.
DOM 노드 자체도 ref에 속합니다. DOM 요소를 상태에 저장할 수도 있지만, 그렇게 하면 ref 콜백이 실행된 후 리렌더링이 트리거됩니다. 대부분의 경우, 노드를 다르게 렌더링하기 위해서가 아니라 명령형 메서드나 측정을 위해 노드가 필요한 것이므로 ref를 사용하는 것이 맞습니다.
불리언의 함정 (The Boolean Trap)
모든 플래그(flag)에 개별 훅을 할당하면 관련 UI 관심사들이 무분별하게 늘어나는 경향이 있습니다. isLoading, isError, isSuccess가 각각 세 개의 별도 불리언으로 정의된 컴포넌트를 흔히 볼 수 있습니다. 문제는 이 세 상태가 독립적이지 않다는 점입니다. 만약 isLoading과 isSuccess가 모두 true라면 UI는 불가능한 상태에 놓이게 되지만, TypeScript와 React는 이를 그대로 렌더링하도록 허용합니다.
관련 상태를 그룹화하면 이러한 잘못된 조합을 방지할 수 있습니다. 세 개의 불리언 대신, 'idle', 'loading', 'success', 'error'와 같은 단일 상태 문자열을 추적하세요. 한 번에 하나만 활성화될 수 있으므로, 타입 레벨에서 불가능한 상태를 제거할 수 있습니다. 데이터가 더 복잡하다면 판별 가능한 유니온(discriminated union)을 가진 객체를 사용하여 더욱 깔끔하게 정리할 수 있습니다. 동일한 이벤트 핸들러 내에서 여러 개의 useState 호출을 업데이트하고 있다면, 이는 해당 값들이 함께 있어야 한다는 신호입니다.
또 다른 useState 대신 useReducer를 사용하세요
상태 업데이트가 마치 두더지 잡기 게임처럼 변하는 시점이 있습니다. 하나의 함수 안에서 setA를 호출하고, 이어서 setB를 호출하고, 조건에 따라 setC를 호출하는 식입니다. 이 코드를 읽는 다음 개발자는 컴포넌트가 실제로 무엇을 하는지 이해하기 위해 그 순서를 일일이 추적해야 합니다.
여기서 useReducer가 빛을 발합니다. useReducer가 useState보다 더 고급 기능이라서 대체하는 것이 아닙니다. 로직이 그것을 요구하기 때문에 대체하는 것입니다. 리듀서는 상태가 어떻게 변하는지를 중앙 집중화합니다. 이벤트 핸들러 곳곳에 명령형 코드를 뿌리는 대신, 의도를 디스패치합니다: dispatch({ type: 'submitted' }). 리듀서가 다음 상태가 어떤 모습일지 결정합니다. 상태 로직이 순수 함수(pure function)이므로 테스트가 매우 쉬워집니다. 또한 모든 변경 사항이 추적 가능한 액션(action)으로 남기 때문에 디버깅도 훨씬 쉬워집니다.
리듀서를 도입하기 위해 반드시 Redux가 필요한 것은 아닙니다. 세 개 이상의 상태 변수가 함께 업데이트되거나, 다음 상태가 이전 상태에 크게 의존하는 경우, 리듀서를 사용하면 컴포넌트를 획기적으로 단순화할 수 있습니다.
상태가 실제로 존재하는 위치
때로는 문제는 상태를 어떻게 저장하느냐가 아니라, 어디에 저장하느냐입니다. 흔히 하는 실수는 단지 다른 곳에서 필요할 수도 있다는 이유만으로 상태를 부모 컴포넌트로 끌어올리는(hoisting) 것입니다. 만약 단 하나의 말단(leaf) 컴포넌트만 특정 상태를 사용한다면, 그곳에 그대로 두세요. 이것이 바로 colocation이며, 이는 변경 사항이 미치는 영향 범위(blast radius)를 줄여줍니다. 자식 컴포넌트가 열렸다는 이유만으로 부모 컴포넌트가 다시 렌더링되게 하지 마세요.
