useState가 여러 개일 때 useReducer는 언제 꺼내야 할까
react-doctor의 prefer-useReducer 규칙("관련된 useState가 너무 많다")을 처리하면서 정리한 기준이다.
useState를 여러 개 나열하면 생기는 문제
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [highlightedIndex, setHighlightedIndex] = useState(0);
드롭다운 하나에 이런 상태가 3~5개씩 붙는 건 흔하다. 문제는 여러 개를 동시에 바꿔야 하는 순간이다.
// 항목 선택 시
setIsOpen(false);
setSearchQuery('');
setHighlightedIndex(0);
// 바깥 클릭 시 - 같은 세 줄 반복
setIsOpen(false);
setSearchQuery('');
setHighlightedIndex(0);
// 포커스 시 - 비슷하지만 조건이 다름
setIsOpen(true);
setHighlightedIndex(0);
if (selectedValue) setSearchQuery('');
- 같은 setter 조합이 여러 핸들러에 중복된다. 규칙을 바꾸려면 모든 핸들러를 찾아 고쳐야 한다.
- 하나를 빠뜨리기 쉽다. 닫기 로직 한 곳에서
setSearchQuery('')를 빠뜨리면 다시 열었을 때 이전 검색어가 남는다. - 상태가 어떻게 바뀔 수 있는지가 코드 전체에 흩어진다.
useReducer로 옮기면
"상태가 바뀌는 방식"을 reducer 함수 하나에 모으고, 컴포넌트는 "무슨 일이 일어났는지"(action)만 알린다.
type DropdownState = {
isOpen: boolean;
searchQuery: string;
highlightedIndex: number;
};
type DropdownAction =
| { type: 'CLOSE_AND_RESET' }
| { type: 'OPEN_FOR_FOCUS'; clearSearch: boolean }
| { type: 'SET_SEARCH_QUERY'; query: string }
| { type: 'SET_HIGHLIGHTED_INDEX'; index: number };
const dropdownReducer = (state: DropdownState, action: DropdownAction): DropdownState => {
switch (action.type) {
case 'CLOSE_AND_RESET':
return { isOpen: false, searchQuery: '', highlightedIndex: 0 };
case 'OPEN_FOR_FOCUS':
return {
...state,
isOpen: true,
highlightedIndex: 0,
searchQuery: action.clearSearch ? '' : state.searchQuery,
};
case 'SET_SEARCH_QUERY':
return { ...state, isOpen: true, searchQuery: action.query, highlightedIndex: 0 };
case 'SET_HIGHLIGHTED_INDEX':
return { ...state, highlightedIndex: action.index };
}
};
const [state, dispatch] = useReducer(dropdownReducer, initialState);
const handleSelect = (value: string) => {
onValueChange(value);
dispatch({ type: 'CLOSE_AND_RESET' });
};
const handleClickOutside = () => dispatch({ type: 'CLOSE_AND_RESET' });
이제 "닫을 때 무엇이 바뀌는가"는 CLOSE_AND_RESET 하나만 보면 된다. action 타입을 유니온으로 정의하면 switch가 모든 경우를 처리했는지 TypeScript가 확인해 준다. reducer는 순수 함수라 컴포넌트를 렌더링하지 않고 dropdownReducer(state, action)을 직접 호출해 테스트할 수 있다.
state, dispatch, reducer의 역할
자판기에 비유하면 이해가 쉽다.
state: 자판기 화면에 표시된 현재 값. 읽기만 한다.dispatch: 버튼을 누르는 동작. "이런 일이 일어났다"는 action을 전달한다.reducer: 자판기 내부 장치. "현재 상태 + 눌린 버튼"을 보고 다음 상태를 계산한다. 컴포넌트가 직접 호출하지 않고 React가 호출한다.
dispatch({ type: 'CLOSE_AND_RESET' })
→ React가 dropdownReducer(현재 state, action) 실행
→ 반환값이 새 state
→ 새 state로 리렌더링
useState와 비교하면 차이가 분명하다.
// useState - 호출하는 쪽이 "다음 값"을 계산해서 넘김
setIsOpen(false);
// useReducer - 호출하는 쪽은 "무슨 일"만 알리고, 다음 값 계산은 reducer가 전담
dispatch({ type: 'CLOSE_AND_RESET' });
전부 합쳐야 할까? 부분 통합을 고른 이유
지적받은 콤보박스 컴포넌트에는 isOpen, searchQuery, highlightedIndex, selectedValue, selectedLabel 다섯 개의 상태가 있었다. 핸들러를 하나씩 읽어 보니 바뀌는 조합이 달랐다.
- 선택·고정 항목 선택·바깥 클릭:
isOpen+searchQuery+highlightedIndex가 항상 함께 리셋 - 키보드 방향키, 마우스 hover:
highlightedIndex만 단독 변경 - 타이핑:
searchQuery+highlightedIndex, 조건부로isOpen - 외부
valueprop 동기화:selectedValue+selectedLabel만 변경 (나머지와 무관)
같은 사건에서 함께 바뀌는 것은 앞의 세 개뿐이었고, selectedValue·selectedLabel은 독립된 관심사였다. 그래서 세 개만 useReducer로 묶고 두 개는 useState로 남겼다.
const [dropdownState, dispatch] = useReducer(dropdownReducer, initialDropdownState);
// 외부 prop과 동기화되는 별개의 관심사
const [selectedValue, setSelectedValue] = useState(value);
const [selectedLabel, setSelectedLabel] = useState('');
다섯 개를 억지로 한 reducer에 넣었다면 관심사가 다른 action이 섞여 reducer가 오히려 읽기 어려워졌을 것이다.
판단 기준
| 신호 | 결론 |
|---|---|
| 여러 상태가 같은 이벤트에서 항상 함께 바뀐다 | useReducer로 묶기 |
| 상태마다 다른 트리거에서 독립적으로 바뀐다 | useState 유지 |
| 다음 상태가 "이전 상태 + action" 조합으로 분기가 복잡하다 | useReducer(분기를 한 곳에 모아 테스트) |
| 같은 상태 전이 코드가 여러 핸들러에 복붙되어 있다 | useReducer로 묶어 action 재사용 |
| 앱 전역에서 재사용하는 기본 UI라 회귀 위험이 크다 | 확실히 함께 바뀌는 부분만 먼저 통합 |
핵심은 상태의 개수가 아니라 상태 전이가 얼마나 얽혀 있는가다. 개수만 보고 무작정 합치면 관심사가 섞이고, 정말 얽힌 상태를 useState 여러 개로 방치하면 그 얽힘이 컴포넌트 곳곳에 숨는다.