본문으로 건너뛰기

안동민 개발노트

본문 시작

useReducer 훅 소개

useReducer의 state·action·dispatch 흐름으로 여러 상태 전이를 한곳에 모으고 Context와 결합할 때의 책임 경계를 익힙니다.

리액트에서 여러 상태 업데이트가 여러 이벤트 핸들러에 흩어져 읽기 어려울 때, useReducer를 사용하면 상태 전이 규칙을 reducer 함수 한곳으로 모을 수 있습니다.

useReducer가 언제나 useState보다 나은 것은 아닙니다. 단순한 토글이나 입력값은 useState가 더 짧고 직접적이며, reducer는 “무슨 일이 있었는가”와 “그 사건 뒤 상태가 어떻게 바뀌는가”를 분리할 가치가 있을 때 유용합니다.

Context와 결합할 수도 있지만 Context 자체가 전역 상태 저장소가 되는 것은 아닙니다. state는 useReducer를 호출한 컴포넌트가 소유하고, Context는 선택한 Provider 하위 트리에 state와 dispatch를 전달합니다.

UI 사건을 action으로 dispatch하고 순수 reducer가 불변 방식으로 다음 상태를 계산한 뒤 React가 다음 렌더에 반영하는 useReducer 상태 전이 계약

Event → dispatch → reducer → render

이벤트 핸들러는 무슨 일이 일어났는지를 action으로 보내고, reducer는 현재 state와 action만으로 다음 state를 계산합니다. 계산 결과는 다음 렌더에 쓰이며, 실행 중인 핸들러의 state는 그 렌더의 snapshot으로 남습니다.

Action 계약

action은 보통 type과 계산에 필요한 최소 정보만 담아, 값 설정 명령보다 발생한 사건을 설명합니다.

한 상호작용이 여러 필드를 바꾸더라도 하나의 의미 있는 action으로 표현할 수 있습니다.

Snapshot과 bailout

dispatch 직후 같은 핸들러에서 읽는 state는 이전 snapshot입니다.

반환값이 현재 state와 Object.is로 같으면 React는 해당 컴포넌트와 자식의 렌더를 건너뜁니다.

  1. UI 사건

    클릭·입력·응답 도착처럼 상태가 바뀌어야 하는 사건이 발생합니다. 핸들러는 다음 state를 직접 저장하는 대신 action을 만듭니다.

  2. dispatch(action)

    안정적인 identity를 가진 dispatch가 action을 React에 전달하고 다음 렌더의 상태 갱신을 요청합니다. 반환값은 없습니다.

  3. 순수 reducer

    reducer(state, action)가 같은 입력에 같은 다음 state를 반환합니다. 객체와 배열을 직접 변형하지 않고, 실제 변경이 있을 때 새 값을 만듭니다.

  4. 다음 렌더

    React는 reducer 반환값을 다음 state로 저장하고 그 snapshot으로 UI를 렌더합니다. 현재 핸들러의 지역 변수는 중간에 바뀌지 않습니다.

불변 갱신

객체는 spread, 배열은 map·filter 같은 방식으로 새 구조를 반환합니다. 의미 있는 변화가 없을 때는 기존 state를 그대로 반환할 수 있습니다.

부수효과 경계

API 요청·타이머·저장소 접근은 reducer 밖에 둡니다. 상호작용 때문에 생긴 작업은 이벤트 핸들러에서, 렌더 결과와 외부 시스템의 동기화는 Effect에서 처리합니다.

구현 전 체크리스트

  • Owner와 scope: 어느 컴포넌트가 state를 소유하고 누가 읽어야 하는가?
  • State shape와 init: 함께 변하는 필드와 불변 조건은 무엇이며 초기화 비용은 큰가?
  • Action vocabulary: 각 상호작용과 최소 payload가 상태 변화의 이유를 드러내는가?
  • Reducer 계약: 모든 분기가 순수하고 불변 갱신을 하며 알 수 없는 action을 분명히 처리하는가?
  • 공급 경계: props로 충분한가, 아니면 제한된 Provider 하위 트리에 state와 dispatch를 공급해야 하는가?

useReducer의 기본 계약

useReducer는 reducer와 초기 인자를 받고, 필요할 때 세 번째 인자로 초기화 함수를 받습니다.

const [state, dispatch] = useReducer(reducer, initialArg, init);
  • reducer(state, action)은 현재 state와 action을 받아 다음 state를 반환하는 순수 함수입니다.
  • initialArg는 초기 state를 계산할 때 쓰는 값입니다. init이 없으면 이 값 자체가 초기 state가 됩니다.
  • init(initialArg)는 선택 사항입니다. 초기 계산 비용이 클 때 초기 state를 지연 생성합니다.
  • state는 현재 렌더의 snapshot입니다.
  • dispatch(action)은 다음 렌더에 반영할 상태 갱신을 요청하며 반환값은 없습니다.

action은 어떤 타입이어도 되지만, 보통 사건을 식별하는 type과 reducer 계산에 필요한 최소 정보를 가진 객체를 사용합니다. 한 상호작용이 여러 필드를 함께 바꾼다면 여러 “값 설정” action보다 하나의 의미 있는 사건으로 표현하는 편이 전이 이유를 잘 드러냅니다.

reducer가 지켜야 할 규칙

reducer는 렌더 중에 실행되므로 순수해야 합니다.

  • 같은 state와 action에는 같은 다음 state를 반환합니다.
  • state의 객체나 배열을 직접 변경하지 않습니다.
  • 실제 변화가 있을 때 새 객체나 배열을 반환합니다.
  • 의미 있는 변화가 없다면 기존 state를 그대로 반환할 수 있습니다.
  • API 요청, 타이머, 저장소 접근 같은 부수효과를 실행하지 않습니다.
  • 알 수 없는 action은 현재 state로 조용히 숨기기보다 오류로 드러내는 방식을 선택할 수 있습니다.

React는 reducer 반환값과 현재 state를 Object.is로 비교합니다. 둘이 같으면 해당 컴포넌트와 자식의 렌더를 건너뜁니다. React가 컴포넌트 함수를 먼저 호출한 뒤 결과를 버릴 수는 있으므로, 이 최적화에 기대어 렌더 중 부수효과를 작성해서는 안 됩니다.

개발 환경의 Strict Mode에서는 우발적인 불순성을 찾기 위해 reducer와 initializer를 두 번 호출할 수 있습니다. 한 번의 결과만 사용되므로 순수한 함수라면 동작이 달라지지 않습니다.


카운터로 보는 action과 상태 전이

src/components/CounterWithReducer.jsx
import { useReducer } from 'react';

const initialState = { count: 0 };

function counterReducer(state, action) {
  switch (action.type) {
    case 'incremented':
      return { ...state, count: state.count + 1 };
    case 'decremented':
      return { ...state, count: state.count - 1 };
    case 'reset':
      return initialState;
    case 'amountAdded':
      return { ...state, count: state.count + action.amount };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

export default function CounterWithReducer() {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  return (
    <section>
      <h2>Reducer 카운터</h2>
      <p aria-live="polite">현재 카운트: {state.count}</p>
      <button onClick={() => dispatch({ type: 'incremented' })}>
        증가
      </button>
      <button onClick={() => dispatch({ type: 'decremented' })}>
        감소
      </button>
      <button onClick={() => dispatch({ type: 'reset' })}>
        초기화
      </button>
      <button
        onClick={() => dispatch({ type: 'amountAdded', amount: 5 })}
      >
        5 추가
      </button>
    </section>
  );
}

버튼은 다음 count를 직접 정하지 않고 발생한 사건을 action으로 보냅니다. reducer는 현재 state와 action만으로 다음 state를 계산하고, React는 그 반환값으로 다음 렌더를 수행합니다.

dispatch를 호출해도 실행 중인 이벤트 핸들러의 state 변수는 즉시 바뀌지 않습니다.

function handleIncrement() {
  dispatch({ type: 'incremented' });
  console.log(state.count); // 이 렌더가 받은 이전 snapshot
}

dispatch의 identity는 안정적입니다. 따라서 이 함수를 props로 전달할 때 함수 identity 자체가 매번 바뀌지는 않습니다. 다만 부모가 렌더되면 자식도 실행될 수 있으므로, “dispatch를 전달하면 불필요한 렌더가 없다”라고 단정할 수는 없습니다. memoization과 bailout은 별도의 렌더 경계 문제입니다.

부수효과도 reducer 밖에 둡니다. 사용자 상호작용 때문에 생긴 요청은 이벤트 핸들러에서 처리하고, 렌더 결과를 외부 시스템과 동기화해야 할 때는 Effect를 사용합니다.


useStateuseReducer 선택

useState의 함수형 updater도 이전 state를 바탕으로 다음 값을 계산할 수 있으므로, “이전 값에 의존한다”는 사실 하나만으로 reducer가 필요한 것은 아닙니다.

다음과 같은 신호를 함께 봅니다.

  • 단순하고 독립적인 값의 짧은 갱신은 useState가 더 읽기 쉽습니다.
  • 여러 이벤트 핸들러에 비슷한 갱신 코드와 분기가 반복되면 reducer가 규칙을 모으는 데 도움이 됩니다.
  • 하나의 사건이 관련 필드 여러 개를 함께 바꾸거나 불변 조건을 지켜야 하면 reducer의 action 경계가 유용합니다.
  • 순수 전이 함수를 UI와 분리해 테스트하거나 action별로 변화 이유를 추적할 필요가 있으면 reducer가 적합할 수 있습니다.
  • reducer는 action과 분기 코드를 더 작성해야 하므로 단순한 상태까지 모두 옮길 필요는 없습니다.

한 컴포넌트 안에서 useStateuseReducer를 함께 사용해도 됩니다. 선택 기준은 state 개수나 객체 여부가 아니라 전이 규칙의 복잡도와 설명 가능성입니다.

상태 전이 규칙의 복잡도로 useState와 useReducer를 선택하고 컴포넌트의 소유권, reducer의 계산 책임, Context Provider의 공급 범위를 구분하는 결정표

Choose → own → calculate → provide

선택 기준은 state 필드 수가 아니라 전이 규칙을 어디에서 가장 명확하게 설명할 수 있는지입니다. reducer는 다음 state를 계산하고, 상태를 가진 컴포넌트가 소유하며, Context는 선택한 하위 범위에 값을 공급합니다.

useStateuseReducer를 고르는 실무 신호
판단 신호 useState useReducer
전이 규칙 단순 토글·입력처럼 setter와 updater만으로 의도가 바로 읽힙니다. 여러 이벤트 핸들러에 분기와 상태 갱신 규칙이 흩어져 있습니다.
관련 값 값이 독립적이고 함께 지켜야 할 불변 조건이 적습니다. 하나의 사건이 관련 필드 여러 개를 함께 바꾸거나 전이 조건이 얽힙니다.
설명 방식 “다음 값을 무엇으로 둘지”를 가까운 코드에서 설명하는 편이 짧습니다. “무슨 일이 있었고 어떻게 바뀌는지”를 action과 reducer로 분리하면 선명합니다.
검증 비용 컴포넌트 동작을 읽는 것만으로 변경 규칙을 쉽게 추적할 수 있습니다. 순수 reducer를 따로 테스트하고 action 로그로 변화 이유를 추적할 가치가 큽니다.
코드 비용 초기 보일러플레이트가 적습니다. action과 reducer 코드가 늘어나는 대신 복잡한 갱신 규칙을 한곳에 모읍니다.
  1. useState

    규칙이 짧고 값이 독립적이면 setter나 updater가 더 직접적입니다.

  2. useReducer

    분기·불변 조건·관련 필드 갱신이 여러 핸들러에 흩어지면 action과 reducer로 모읍니다.

  3. 비용을 함께 본다

    reducer는 테스트와 추적 경계를 주지만 action과 분기 코드를 더 작성해야 합니다. 두 훅을 한 컴포넌트에서 섞어도 됩니다.

State owner

Hook을 호출한 컴포넌트

useStateuseReducer를 직접 또는 custom Hook을 통해 호출한 컴포넌트가 state를 소유합니다. custom Hook은 별도 owner가 아닙니다.

Reducer

다음 state 계산

현재 state와 action을 받아 순수하고 불변인 전이 규칙만 수행합니다. API·타이머·저장소 접근과 공급 범위는 책임 밖입니다.

Context

값 공급과 범위

React 19에서는 Context 객체를 Provider로 렌더하고 value를 줍니다. 소비자는 가장 가까운 일치 Provider의 값을 읽습니다.

Context는 전역 상태나 owner가 아니라 선택한 하위 트리의 전달 채널입니다.

구현 전 체크리스트

  • Owner와 scope: 어느 컴포넌트가 state를 소유하고 누가 읽어야 하는가?
  • State shape와 init: 함께 변하는 필드와 불변 조건은 무엇이며 초기화 비용은 큰가?
  • Action vocabulary: 각 상호작용과 최소 payload가 상태 변화의 이유를 드러내는가?
  • Reducer 계약: 모든 분기가 순수하고 불변 갱신을 하며 알 수 없는 action을 분명히 처리하는가?
  • 공급 경계: props로 충분한가, 아니면 제한된 Provider 하위 트리에 state와 dispatch를 공급해야 하는가?

dispatch의 identity는 안정적이지만, 이를 props로 전달하는 것만으로 자식 렌더가 사라지는 것은 아닙니다. 부모 렌더와 memoization bailout은 별도로 판단합니다.


reducer와 Context의 책임 분리

reducer는 상태를 저장하거나 공급 범위를 정하지 않습니다. 현재 state와 action을 받아 다음 state를 계산하는 규칙입니다.

state는 useReducer를 직접 또는 custom Hook을 통해 호출한 컴포넌트가 소유합니다. custom Hook은 상태 로직을 캡슐화하지만 별도의 owner가 되지는 않습니다. 여러 하위 컴포넌트가 state와 dispatch를 읽어야 하고 props 전달이 반복된다면, 소유 컴포넌트가 Context Provider 역할까지 맡을 수 있습니다.

아래 예시는 앞 카운터의 reducer와 초기 state를 별도 모듈로 옮겨 export했다고 가정합니다.

src/context/CounterContext.jsx
import { createContext, useReducer } from 'react';
import { counterReducer, initialState } from '../state/counterReducer.js';

export const CounterStateContext = createContext(null);
export const CounterDispatchContext = createContext(null);

export function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(counterReducer, initialState);

  return (
    <CounterStateContext value={state}>
      <CounterDispatchContext value={dispatch}>
        {children}
      </CounterDispatchContext>
    </CounterStateContext>
  );
}

이 프로젝트의 React 19에서는 Context 객체를 직접 Provider로 렌더하고 value를 전달합니다. 소비자는 트리에서 가장 가까운 일치 Provider의 값을 읽습니다.

Context는 어디까지 전달할지를 정하는 채널이고 reducer는 어떻게 바꿀지를 정하는 계산 규칙입니다. 이 예시에서는 useReducer를 호출한 CounterProvider가 state의 owner이며, Context가 자동으로 앱 전체의 전역 상태가 되는 것은 아닙니다. 소비 범위가 작거나 props가 구조를 더 분명하게 드러내면 Context 없이 전달하는 편도 충분히 좋습니다.


"useReducer 훅 소개"는 여기까지입니다.

상태 변화의 이유를 action으로 표현하고, 순수 reducer가 불변 방식으로 다음 state를 계산하며, React가 이를 다음 렌더의 snapshot으로 반영하는 흐름을 살펴봤습니다.

다음 절에서는 소유 컴포넌트가 관리하는 state와 dispatch를 Context로 공급하는 구조를 더 자세히 다룹니다.