blog

useState가 여러 개일 때 useReducer는 언제 꺼내야 할까

Front-end / React#React#useReducer#useState#상태 관리#리팩터링

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('');
  1. 같은 setter 조합이 여러 핸들러에 중복된다. 규칙을 바꾸려면 모든 핸들러를 찾아 고쳐야 한다.
  2. 하나를 빠뜨리기 쉽다. 닫기 로직 한 곳에서 setSearchQuery('')를 빠뜨리면 다시 열었을 때 이전 검색어가 남는다.
  3. 상태가 어떻게 바뀔 수 있는지가 코드 전체에 흩어진다.

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
  • 외부 value prop 동기화: 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 여러 개로 방치하면 그 얽힘이 컴포넌트 곳곳에 숨는다.

참고