[React 공식문서 정독] Reducer를 활용한 상태 관리

김진서·2025년 5월 7일

우아한테크코스 7기

목록 보기
37/56
post-thumbnail

0. 복잡한 State 관리에 대한 고민

React 컴포넌트에서 많은 State 업데이트 로직이 여러 이벤트 핸들러에 분산되면 코드가 복잡해진다. 이러한 복잡성을 해소하기 위해, State 업데이트 로직을 컴포넌트 외부의 단일 함수인 Reducer로 통합할 수 있다.

ReduceruseState와는 다른 방식으로 State를 관리한다. State를 직접 설정하는 대신, 이벤트 핸들러에서 사용자의 Actiondispatch하고, Reducer 함수가 이 Action에 따라 State 업데이트 방법을 결정한다. 이를 통해 코드 가독성을 높이고 State 로직을 한 곳에 집중시킬 수 있다.
Flux Pattern Architecture in React

1. Reducer?

  • Reducer는 컴포넌트 외부에서 State 업데이트 로직을 통합하는 단일 함수이다.
  • 현재 State사용자의 action 객체를 받아, 다음 State를 반환한다. React는 이 반환값으로 State를 설정한다.
  • 이벤트 핸들러는 action(무엇을 했는지)dispatch하고, Reducer 함수가 State가 '어떻게' 업데이트될지 결정한다.
  • 이는 배열의 reduce() 연산과 유사한 아이디어를 공유한다.
  • useReducer Hook을 사용하여 컴포넌트에 적용한다.
import React, { useReducer } from 'react';

// 1) 초기 상태 정의
const initialState = { count: 0 };

// 2) reducer 함수: (현재 state, action) → 다음 state 반환
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

// 3) 컴포넌트에서 useReducer 사용
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count}</p>
      {/* 이벤트 핸들러는 action 객체만 dispatch */}
      <button onClick={() => dispatch({ type: 'decrement' })}></button>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
    </div>
  );
}

export default Counter;

2. 왜 Reducer? (vs useState)

  • 복잡한 State 로직 중앙 집중화: 여러 이벤트 핸들러에 흩어진 복잡한 State 업데이트 로직을 컴포넌트 외부 Reducer 함수로 통합하여 코드 복잡성을 줄이고 가독성을 높인다.
  • 역할 분리: 이벤트 핸들러는 '사용자의 Action'(무엇)만 dispatch하고, Reducer 함수가 State 업데이트 방법('어떻게')을 결정한다.
  • 주요 이점 (useState 대비):
    • 가독성: 복잡한 State 로직 분리로 코드 간결성 증대.
    • 디버깅: Action 기반 추적으로 버그 원인 파악 용이.
    • 테스팅: 컴포넌트와 독립적인 순수 함수로 단위 테스트 용이.
  • 고려 사항: useState보다 초기 코드량이 많을 수 있다.
  • 사용 권장: State 로직이 복잡하거나 버그 추적/테스트가 필요할 때 고려하며, useState와 혼용 가능하다.
import React, { useState, useReducer } from 'react';

// 1) useState 예시: 여러 개의 독립된 상태와 핸들러
function ProfileWithState() {
  const [name, setName] = useState('');
  const [age, setAge]   = useState(0);
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = () => {
    setSubmitting(true);
    // …폼 제출 로직
  };

  return (
    <div>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
        placeholder="Name"
      />
      <input
        type="number"
        value={age}
        onChange={e => setAge(+e.target.value)}
        placeholder="Age"
      />
      <button onClick={handleSubmit}>
        {submitting ? 'Submitting…' : 'Submit'}
      </button>
    </div>
  );
}

// 2) useReducer 예시: 모든 State 업데이트 로직을 중앙 집중화
const initialState = {
  name: '',
  age: 0,
  submitting: false,
};

function reducer(state, action) {
  switch (action.type) {
    case 'changeName':
      return { ...state, name: action.payload };
    case 'changeAge':
      return { ...state, age: action.payload };
    case 'submit':
      return { ...state, submitting: true };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

function ProfileWithReducer() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { name, age, submitting } = state;

  return (
    <div>
      <input
        value={name}
        onChange={e =>
          dispatch({ type: 'changeName', payload: e.target.value })
        }
        placeholder="Name"
      />
      <input
        type="number"
        value={age}
        onChange={e =>
          dispatch({ type: 'changeAge', payload: +e.target.value })
        }
        placeholder="Age"
      />
      <button onClick={() => dispatch({ type: 'submit' })}>
        {submitting ? 'Submitting…' : 'Submit'}
      </button>
    </div>
  );
}

export { ProfileWithState, ProfileWithReducer };

3. useState에서 useReducer로의 리팩터링 3단계

  1. State 설정에서 Action Dispatch로 변경: 이벤트 핸들러에서 setTasks(...)처럼 State를 직접 설정하는 로직을 제거하고, 대신 사용자의 Action (무엇이 일어났는지) 객체를 dispatch 하도록 변경한다. Action 객체는 type 필드와 필요한 정보(payload)를 포함한다.
// Before: setTasks 직접 사용
function Todo() {
  const [tasks, setTasks] = useState([]);
  const removeTask = id => {
    setTasks(prev => prev.filter(t => t.id !== id));
  };
  // …
  <button onClick={() => removeTask(task.id)}>Remove</button>
}

// After: removeTask → dispatch
function Todo() {
  // dispatch는 다음 단계에서 정의
  const removeTask = id => {
    dispatch({ type: 'remove', payload: id });
  };
  // …
  <button onClick={() => removeTask(task.id)}>Remove</button>
}
  1. Reducer 함수 작성: 컴포넌트 외부에 reducer(state, action) 함수를 작성하고, 기존의 모든 State 업데이트 로직을 이 함수 안으로 옮긴다. Reduceraction.type에 따라 다음 State를 반환해야 하며, 순수 함수여야 한다(Side EffectState 변이 금지). switch문 사용이 컨벤션이다. Immer 사용 시 변이 스타일 작성 가능하다.
// reducer.js (또는 컴포넌트 외부)
export function reducer(state, action) {
  switch (action.type) {
    case 'add':
      return [...state, action.payload];
    case 'remove':
      return state.filter(t => t.id !== action.payload);
    case 'toggle':
      return state.map(t =>
        t.id === action.payload ? { ...t, done: !t.done } : t
      );
    default:
      return state;
  }
}
  1. 컴포넌트에서 Reducer 사용: useReducer Hook을 import 하고, useStateuseReducer(reducer 함수, 초기 State)로 변경한다. useReducer현재 Statedispatch 함수를 반환한다. Reducer 함수는 컴포넌트 하단 또는 별도 파일에 둘 수 있다.
import React, { useReducer } from 'react';
import { reducer } from './reducer';

const initialTasks = [];

function Todo() {
  const [tasks, dispatch] = useReducer(reducer, initialTasks);

  return (
    <div>
      {tasks.map(t => (
        <div key={t.id}>
          <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>
            {t.text}
          </span>
          <button
            onClick={() => dispatch({ type: 'toggle', payload: t.id })}
          >
            Toggle
          </button>
          <button
            onClick={() => dispatch({ type: 'remove', payload: t.id })}
          >
            Remove
          </button>
        </div>
      ))}
      <button
        onClick={() =>
          dispatch({ type: 'add', payload: { id: Date.now(), text: 'New', done: false }})
        }
      >
        Add Task
      </button>
    </div>
  );
}

export default Todo;

4. ReducerContext의 결합 (State를 깊게 전달하기)

  • 문제 인식: useReducer로 관리되는 State(tasks)dispatch 함수는 기본적으로 해당 컴포넌트(TaskApp) 내에서만 유효하다. 하위 컴포넌트가 State를 읽거나 업데이트 액션을 보내려면 props로 명시적으로 전달해야 하며, 트리가 깊어지면 Prop Drilling 문제가 발생한다.
  • 해결책: ReducerContext를 함께 사용하여 이 문제를 해결할 수 있다. Context를 통해 Statedispatch 함수를 트리의 어떤 하위 컴포넌트에게도 props 전달 없이 제공할 수 있다.
  • 결합 방법:
  1. Statedispatch를 위한 두 개의 Context를 생성한다. (예: TasksContext for state, TasksDispatchContext for dispatch).
import { createContext } from 'react';

export const TasksStateContext = createContext();
export const TasksDispatchContext = createContext();
  1. useReducer를 사용하는 최상위 컴포넌트(TaskApp)에서 이 두 ContextProvider로 하위 트리를 감싸고, 각각 Statedispatch 함수를 value로 제공한다.
import React, { useReducer } from 'react';
import { TasksStateContext, TasksDispatchContext } from './TasksContext';
import { reducer, initialTasks } from './tasksReducer';

function TasksProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialTasks);

  return (
    <TasksStateContext.Provider value={state}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksStateContext.Provider>
  );
}

export default TasksProvider;
  1. Statedispatch가 필요한 하위 컴포넌트에서 useContext Hook을 사용하여 Context 값을 읽어온다.
import React, { useContext } from 'react';
import { TasksStateContext, TasksDispatchContext } from './TasksContext';

function TaskList() {
  const tasks = useContext(TasksStateContext);
  const dispatch = useContext(TasksDispatchContext);

  return (
    <ul>
      {tasks.map(t => (
        <li key={t.id}>
          <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>
            {t.text}
          </span>
          <button onClick={() => dispatch({ type: 'toggle', payload: t.id })}>
            Toggle
          </button>
        </li>
      ))}
    </ul>
  );
}
  • 코드 구성 개선: 선택적으로 Context 선언, Reducer 함수, 그리고 Provider 컴포넌트(TasksProvider)를 하나의 파일로 통합하여 컴포넌트 코드를 더 깔끔하게 관리할 수 있다. Statedispatch Context 사용을 위한 커스텀 Hook (useTasks, useTasksDispatch)을 Export하여 사용 편의성을 높일 수도 있다.
// TasksProvider.js
import React, { createContext, useReducer, useContext } from 'react';

// 1) Contexts
const TasksStateContext    = createContext();
const TasksDispatchContext = createContext();

// 2) 초기값 + reducer
const initialTasks = [];
function reducer(state, action) {
  switch (action.type) {
    case 'add':
      return [...state, action.payload];
    case 'remove':
      return state.filter(t => t.id !== action.payload);
    case 'toggle':
      return state.map(t =>
        t.id === action.payload ? { ...t, done: !t.done } : t
      );
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

// 3) Provider 컴포넌트
export function TasksProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialTasks);
  return (
    <TasksStateContext.Provider value={state}>
      <TasksDispatchContext.Provider value={dispatch}>
        {children}
      </TasksDispatchContext.Provider>
    </TasksStateContext.Provider>
  );
}

// 4) 커스텀 Hook
export function useTasks() {
  const context = useContext(TasksStateContext);
  if (context === undefined) throw new Error('useTasks must be inside TasksProvider');
  return context;
}

export function useTasksDispatch() {
  const context = useContext(TasksDispatchContext);
  if (context === undefined) throw new Error('useTasksDispatch must be inside TasksProvider');
  return context;
}
// App.js
import React from 'react';
import { TasksProvider, useTasks, useTasksDispatch } from './TasksProvider';

function NewTaskForm() {
  const dispatch = useTasksDispatch();
  const addNew = text =>
    dispatch({ type: 'add', payload: { id: Date.now(), text, done: false } });

  return (
    <button onClick={() => addNew('New Task')}>
      Add Task
    </button>
  );
}

function TaskList() {
  const tasks = useTasks();
  const dispatch = useTasksDispatch();

  return (
    <ul>
      {tasks.map(t => (
        <li key={t.id}>
          {t.text}
          <button onClick={() => dispatch({ type: 'remove', payload: t.id })}></button>
        </li>
      ))}
    </ul>
  );
}

export default function App() {
  return (
    <TasksProvider>
      <NewTaskForm />
      <TaskList />
    </TasksProvider>
  );
}
  • 결론: ReducerContext를 결합하면 Prop Drilling 없이도 복잡한 State 관리를 효과적으로 확장할 수 있다.

5. 좋은 Reducer 작성 팁

  • Reducer는 순수해야 한다. Reducer현재 StateAction을 인자로 받아 다음 State를 반환하는 순수 함수여야 한다. 즉, 동일한 입력(stateaction)에 대해 항상 동일한 출력(다음 state)을 반환해야 하며, 컴포넌트 외부의 것에 영향을 미치는 Side Effect(네트워크 요청, setTimeout 등)를 발생시키거나 State를 직접 변이해서는 안 된다. (Immer 라이브러리를 사용하면 내부적으로 불변성을 유지하면서 변이 스타일로 코드를 작성할 수 있다.) Reducer렌더링 중에 실행될 수 있기 때문에 순수 함수 규칙이 중요하다.

  • Action은 단일 사용자 상호작용을 설명해야 한다. 하나의 사용자 상호작용(예: 'Reset' 버튼 클릭)이 여러 State 값의 변경을 유발하더라도, 단일 Action 객체를 dispatch하는 것이 좋다. Action 객체는 일반적으로 type 문자열과 필요한 정보를 포함하여 '무엇이 일어났는지'를 명시한다. 이를 통해 Reducer에서 Action 로그를 볼 때, 어떤 사용자 상호작용이 State 변경을 일으켰는지 쉽게 파악하고 디버깅할 수 있다.

import produce from 'immer';

// 1) 순수 Reducer + Immer
function reducer(state, action) {
  return produce(state, draft => {
    switch (action.type) {
      case 'reset':
        // 하나의 액션으로 여러 필드 초기화
        draft.name = '';
        draft.age  = 0;
        return;
      case 'updateName':
        draft.name = action.payload;
        return;
      default:
        return;
    }
  });
}

// 2) dispatch 예시: Reset 버튼 클릭 시
<button onClick={() => dispatch({ type: 'reset' })}>
  Reset
</button>

결론: Reducer 사용을 통해 State 관리 효율 높이기

  • Reducer는 복잡한 State 로직을 효과적으로 관리하고 테스트 및 디버깅을 용이하게 하는 강력한 도구다.
  • Context와 함께 사용하면 Prop Drilling 없이 State와 업데이트 로직을 앱 전체에 쉽게 전달할 수 있으며, 이는 앱 확장에 도움이 된다.
  • Reducers를 모든 곳에 사용할 필요는 없으며, 필요에 따라 useState와 혼합하여 사용할 수 있다.
profile
PAy IT forwaRD를 실천하는 프론트엔드 개발자.

0개의 댓글