icon
4장 : React 훅 기초

useReducer

우리는 useContext 훅을 통해 컴포넌트 간 데이터 공유를 더 효율적으로 처리하는 방법을 배웠습니다. 이제 useState 훅만으로는 감당하기 어려운 복잡한 상태 로직을 관리할 때 유용한 또 다른 핵심 훅인 useReducer에 대해 알아볼 차례입니다.

useState는 단순한 값(숫자, 문자열, 불리언)이나 독립적인 객체/배열 상태를 다룰 때 매우 직관적이고 편리합니다. 하지만 여러 개의 하위 상태가 서로 연관되어 있거나, 상태 변경 로직이 복잡하여 여러 setState 호출이 필요한 경우, useState만으로는 코드가 길어지고 유지보수가 어려워질 수 있습니다. 이럴 때 useReducer가 빛을 발합니다.

useReducer는 Redux와 같은 상태 관리 라이브러리의 핵심 개념인 리듀서 패턴을 리액트 훅으로 가져온 것입니다. 이를 통해 상태 업데이트 로직을 컴포넌트로부터 분리하여 더 예측 가능하고 테스트하기 쉬운 코드를 작성할 수 있습니다.


useReducer의 기본 개념

useReducer 훅은 세 가지 주요 요소로 구성됩니다.

  1. reducer 함수

    • (state, action) => newState 형태를 가지는 순수 함수(Pure Function)입니다.
    • 현재 상태(state)와 발생한 action을 인자로 받아서, 그 action에 따라 새로운 상태(newState)를 반환합니다.
    • reducer 함수 내부에서는 state를 직접 변경해서는 안 되며(불변성 유지), 항상 새로운 상태 객체를 반환해야 합니다.
    • reducer는 사이드 이펙트(Side Effect)를 가지면 안 됩니다. (useEffect에서 사이드 이펙트를 처리합니다.)
  2. dispatch 함수

    • useReducer가 반환하는 함수입니다.
    • dispatch(action) 형태로 호출하여 reducer 함수에게 action을 전달합니다.
    • action은 일반적으로 { type: 'ACTION_TYPE', payload: 'data' }와 같은 객체 형태를 가집니다.
  3. initialState (초기 상태)

    • useReducer에 전달하는 초기 상태 값입니다. useState와 유사합니다.
useReducer 기본 구조
import React, { useReducer } from 'react';

// 1. reducer 함수 정의: (state, action) => newState
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'RESET':
      return { count: 0 };
    default:
      throw new Error(); // 알 수 없는 액션 타입일 경우 에러 발생
  }
}

function CounterWithReducer() {
  // 2. useReducer 훅 사용: [현재 상태, dispatch 함수] = useReducer(reducer 함수, 초기 상태);
  const [state, dispatch] = useReducer(reducer, { count: 0 }); // 초기 상태는 객체 형태

  return (
    <div style={{ border: '1px solid #7B1FA2', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>useReducer 카운터</h2>
      <p>카운트: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>증가</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })} style={{ marginLeft: '10px' }}>감소</button>
      <button onClick={() => dispatch({ type: 'RESET' })} style={{ marginLeft: '10px' }}>리셋</button>
    </div>
  );
}

export default CounterWithReducer;
  • reducer 함수는 stateaction을 인자로 받아 새로운 state를 반환합니다. switch 문을 사용하여 action.type에 따라 다른 로직을 수행합니다.
  • dispatch({ type: 'INCREMENT' })와 같이 dispatch 함수를 호출하여 reducer에게 INCREMENT 액션을 전달합니다.
  • state.count로 현재 카운트 값을 접근합니다.

useState vs useReducer

특징useStateuseReducer
복잡성단순한 값 또는 독립적인 여러 값복잡한 상태 로직, 여러 하위 상태가 연관된 경우
가독성간단한 상태 변경 시 직관적상태 변경 로직이 분리되어 있어 더 예측 가능하고 테스트 용이
업데이트setSomething(newValue)dispatch({ type: 'ACTION' })
데이터 타입숫자, 문자열, 불리언, 객체, 배열 등 모든 타입일반적으로 객체나 배열 (여러 상태를 포함)
코드 분리상태 로직이 컴포넌트 내부에 존재상태 로직(reducer)을 컴포넌트 외부로 분리 가능
장점간단하고 배우기 쉬움상태 로직의 예측 가능성, 중앙 집중화, 디버깅 용이
단점복잡한 상태 변경 시 코드 지저분해짐간단한 상태에도 보일러플레이트 코드 증가

언제 useReducer를 사용하는가?

  • 상태 전이(State Transitions)가 복잡할 때: 현재 상태에 따라 다음 상태가 여러 갈래로 결정되는 경우. (예: 로딩/성공/실패 상태)
  • 여러 하위 상태가 서로 연관되어 함께 업데이트되어야 할 때: (예: 폼의 여러 필드 값, 장바구니 항목의 수량과 총액)
  • 상태 로직을 컴포넌트로부터 분리하고 싶을 때: reducer 함수를 별도의 파일로 분리하여 재사용성을 높이거나 테스트를 용이하게 할 수 있습니다.
  • 컴포넌트 간에 dispatch 함수를 전달해야 할 때: dispatch 함수는 useCallback으로 감쌀 필요 없이 항상 동일한 참조를 보장하므로 Context와 함께 사용하기 좋습니다.

useReducer를 활용한 투두(Todo) 리스트

3장 실습에서 만들었던 Todo List 앱의 상태 관리 로직을 useReducer로 변경하여 복잡한 상태 관리의 이점을 느껴봅시다.

todoReducer.js 생성 (리듀서 로직 분리)

src/reducers/todoReducer.js
// src/reducers/todoReducer.js
// 이 파일은 src 폴더 안에 reducers 폴더를 생성하고 그 안에 저장하는 것을 권장합니다.

// 액션 타입 상수 정의 (오타 방지 및 가독성 향상)
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';
export const DELETE_TODO = 'DELETE_TODO';

// (1) reducer 함수 정의: state와 action을 받아 새로운 state를 반환
function todoReducer(state, action) {
  switch (action.type) {
    case ADD_TODO:
      // 새 할 일 ID 생성 (기존 최대 ID + 1)
      const newId = state.length > 0 ? Math.max(...state.map(item => item.id)) + 1 : 1;
      return [
        ...state, // 기존 할 일 목록 복사
        {
          id: newId,
          text: action.payload.text, // payload에서 텍스트 가져옴
          completed: false,
        },
      ];
    case TOGGLE_TODO:
      return state.map((todo) =>
        todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo
      );
    case DELETE_TODO:
      return state.filter((todo) => todo.id !== action.payload.id);
    default:
      // 알 수 없는 액션 타입일 경우 에러 발생 (필수)
      throw new Error(`Unhandled action type: ${action.type}`);
  }
}

export default todoReducer;
  • todoReducer 함수를 별도의 파일로 분리하여 TodoList 컴포넌트와 상태 로직을 분리했습니다.
  • caseaction.type에 따라 다른 상태 업데이트 로직을 정의합니다.
  • action.payload를 통해 필요한 데이터를 전달받습니다.
  • 모든 상태 업데이트는 불변성을 유지하도록 새로운 배열/객체를 반환합니다.

TodoListWithReducer.js 컴포넌트

src/components/TodoListWithReducer.js
// src/components/TodoListWithReducer.js
import React, { useState, useReducer, useEffect } from 'react';
import todoReducer, { ADD_TODO, TOGGLE_TODO, DELETE_TODO } from '../reducers/todoReducer'; // 리듀서와 액션 타입 불러오기

// 초기 상태를 설정하는 함수 (지연 초기화와 로컬 스토리지 결합)
const init = (initialTodos) => {
  try {
    const savedTodos = localStorage.getItem('todoItems_reducer');
    return savedTodos ? JSON.parse(savedTodos) : initialTodos;
  } catch (error) {
    console.error("Failed to parse todos from localStorage", error);
    return initialTodos;
  }
};

function TodoListWithReducer() {
  // 1. useReducer 훅 사용: [현재 상태, dispatch 함수] = useReducer(reducer 함수, 초기 상태, 초기화 함수);
  // 두 번째 인자는 초기 상태, 세 번째 인자는 초기화 함수 (지연 초기화)
  const [todos, dispatch] = useReducer(todoReducer, [], init); // 빈 배열은 init 함수의 initialTodos로 전달

  // 새 할 일 입력 필드를 위한 useState (이것은 단순 상태이므로 useState 유지)
  const [newTodoText, setNewTodoText] = useState('');

  // 2. todos 상태가 변경될 때마다 로컬 스토리지에 저장 (useEffect 사용)
  useEffect(() => {
    localStorage.setItem('todoItems_reducer', JSON.stringify(todos));
  }, [todos]); // todos가 의존성 배열에 있으므로, 변경될 때마다 실행

  // 3. 새 할 일 입력 필드 변경 핸들러
  const handleInputChange = (e) => {
    setNewTodoText(e.target.value);
  };

  // 4. 할 일 추가 핸들러
  const handleAddTodo = (e) => {
    e.preventDefault();
    if (newTodoText.trim() === '') return;

    // dispatch 함수를 호출하여 ADD_TODO 액션 전달
    dispatch({ type: ADD_TODO, payload: { text: newTodoText } });
    setNewTodoText(''); // 입력 필드 초기화
  };

  // 5. 할 일 완료 상태 토글 핸들러
  const handleToggleComplete = (id) => {
    dispatch({ type: TOGGLE_TODO, payload: { id: id } });
  };

  // 6. 할 일 삭제 핸들러
  const handleDeleteTodo = (id) => {
    dispatch({ type: DELETE_TODO, payload: { id: id } });
  };

  // 7. JSX 렌더링
  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '20px', border: '1px solid #7B1FA2', borderRadius: '10px', boxShadow: '0 2px 10px rgba(0,0,0,0.1)' }}>
      <h2>Reducer로 관리하는 Todo List</h2>
      {/* 할 일 추가 폼 */}
      <form onSubmit={handleAddTodo} style={{ display: 'flex', marginBottom: '20px' }}>
        <input
          type="text"
          value={newTodoText}
          onChange={handleInputChange}
          placeholder="새 할 일을 입력하세요..."
          style={{ flexGrow: 1, padding: '10px', fontSize: '16px', border: '1px solid #ddd', borderRadius: '5px 0 0 5px' }}
        />
        <button type="submit" style={{ padding: '10px 15px', fontSize: '16px', backgroundColor: '#7B1FA2', color: 'white', border: 'none', borderRadius: '0 5px 5px 0', cursor: 'pointer' }}>
          추가
        </button>
      </form>

      {/* 할 일 목록 */}
      {todos.length === 0 ? (
        <p style={{ textAlign: 'center', color: '#666' }}>아직 할 일이 없습니다. 새 할 일을 추가해 보세요!</p>
      ) : (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {todos.map((todo) => (
            <li
              key={todo.id}
              style={{
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'space-between',
                padding: '10px',
                borderBottom: '1px dashed #eee',
                backgroundColor: todo.completed ? '#f0f4f8' : 'white', // 조건부 스타일링
              }}
            >
              <div style={{ display: 'flex', alignItems: 'center', flexGrow: 1 }}>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => handleToggleComplete(todo.id)}
                  style={{ marginRight: '10px', transform: 'scale(1.2)' }}
                />
                <span
                  style={{
                    fontSize: '18px',
                    textDecoration: todo.completed ? 'line-through' : 'none',
                    color: todo.completed ? '#999' : '#333',
                  }}
                >
                  {todo.text}
                </span>
              </div>
              <button
                onClick={() => handleDeleteTodo(todo.id)}
                style={{
                  backgroundColor: '#dc3545',
                  color: 'white',
                  border: 'none',
                  padding: '5px 10px',
                  borderRadius: '5px',
                  cursor: 'pointer',
                  fontSize: '14px',
                }}
              >
                삭제
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default TodoListWithReducer;

App.js에서 사용

src/App.js
// src/App.js (수정)
import React from 'react';
import './App.css';
import TodoListWithReducer from './components/TodoListWithReducer'; // useReducer 버전 TodoList 불러오기

function App() {
  return (
    <div className="App">
      <h1>Reducer를 사용한 Todo List</h1>
      <TodoListWithReducer />
    </div>
  );
}

export default App;

useReducer의 초기화 함수

useState와 마찬가지로 useReducer도 초기 상태를 지연 초기화할 수 있습니다. 이는 초기 상태 계산에 비용이 많이 드는 경우 유용합니다. useReducer의 세 번째 인자로 초기화 함수를 전달하면 됩니다.

const [state, dispatch] = useReducer(reducer, initialArg, init);
  • init 함수는 initialArg를 인자로 받아서 실제 초기 상태 값을 반환합니다.
  • 이 함수는 컴포넌트가 처음 렌더링될 때만 실행됩니다.

위 Todo List 예제에서 init 함수를 사용하여 로컬 스토리지에서 할 일 목록을 불러오는 로직을 지연 초기화로 구현했습니다.


useReduceruseContext 함께 사용하기

useReducer로 관리되는 상태와 dispatch 함수를 useContext를 통해 하위 컴포넌트에 전달하면, 전역적인 복잡한 상태 관리를 효율적으로 구현할 수 있습니다. 이는 Redux와 같은 전역 상태 관리 라이브러리의 내부 동작 방식과 유사합니다.

예제: AuthContextuseReducer 결합

src/contexts/AuthContext.js
// src/contexts/AuthContext.js
import React, { createContext, useReducer, useContext, useEffect } from 'react';

// 1. AuthContext 생성
const AuthContext = createContext(null);

// 2. reducer 함수 정의
const authReducer = (state, action) => {
  switch (action.type) {
    case 'LOGIN':
      localStorage.setItem('isLoggedIn', 'true'); // 로그인 시 로컬 스토리지 업데이트
      localStorage.setItem('userToken', action.payload.token);
      return { ...state, isLoggedIn: true, user: action.payload.user, token: action.payload.token };
    case 'LOGOUT':
      localStorage.removeItem('isLoggedIn'); // 로그아웃 시 로컬 스토리지 제거
      localStorage.removeItem('userToken');
      return { ...state, isLoggedIn: false, user: null, token: null };
    case 'SET_INITIAL_STATE': // 초기 로드 액션
      return action.payload;
    default:
      return state;
  }
};

// 3. 초기 상태를 위한 초기화 함수
const initAuth = (initialState) => {
  const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
  const userToken = localStorage.getItem('userToken');
  // 실제 앱에서는 토큰으로 사용자 정보를 다시 불러와야 할 수 있습니다.
  const user = isLoggedIn ? { name: 'Logged In User' } : null; // 예시
  return { ...initialState, isLoggedIn, user, token: userToken };
};

// 4. AuthProvider 컴포넌트 생성
export const AuthProvider = ({ children }) => {
  const [authState, dispatch] = useReducer(authReducer, { isLoggedIn: false, user: null, token: null }, initAuth);

  return (
    <AuthContext.Provider value={{ authState, dispatch }}>
      {children}
    </AuthContext.Provider>
  );
};

// 5. useContext 훅을 사용하기 위한 커스텀 훅 (선택 사항이지만 유용)
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};
src/App.js
// src/App.js (AuthProvider 사용 예시)
import React from 'react';
import './App.css';
import { AuthProvider, useAuth } from './contexts/AuthContext'; // AuthProvider와 useAuth 훅 불러오기

// 로그인/로그아웃 버튼을 표시하는 컴포넌트
function AuthButtons() {
  const { authState, dispatch } = useAuth(); // useAuth 훅 사용

  return (
    <div style={{ margin: '20px', padding: '15px', border: '1px solid #ddd', borderRadius: '8px', textAlign: 'center' }}>
      {authState.isLoggedIn ? (
        <>
          <p>환영합니다, {authState.user.name}님!</p>
          <button onClick={() => dispatch({ type: 'LOGOUT' })} style={{ padding: '10px 20px', backgroundColor: '#f44336', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
            로그아웃
          </button>
        </>
      ) : (
        <>
          <p>로그인이 필요합니다.</p>
          <button onClick={() => dispatch({ type: 'LOGIN', payload: { user: { name: '테스트유저' }, token: 'abc123def456' } })} style={{ padding: '10px 20px', backgroundColor: '#4CAF50', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
            로그인
          </button>
        </>
      )}
    </div>
  );
}

function App() {
  return (
    <div className="App">
      <h1>useReducer + useContext 예시</h1>
      <AuthProvider> {/* AuthProvider로 감싸서 모든 하위 컴포넌트에 인증 상태 제공 */}
        <AuthButtons />
        {/* 다른 컴포넌트들도 useAuth를 사용하여 인증 상태에 접근 가능 */}
      </AuthProvider>
    </div>
  );
}

export default App;

이 예시에서 AuthProvideruseReducer를 사용하여 authStatedispatch 함수를 관리하고, 이 둘을 AuthContext.Providervalue로 전달합니다. useAuth 커스텀 훅을 통해 어떤 하위 컴포넌트든 authStatedispatch 함수에 쉽게 접근하여 인증 상태를 읽고 변경할 수 있게 됩니다.


"useReducer: 복잡한 상태 관리"는 여기까지입니다. 이 장에서는 useReducer 훅의 개념, reducer 함수와 dispatch 함수의 역할, useState와의 차이점, 그리고 복잡한 상태 로직을 효율적으로 관리하기 위한 useReducer의 활용법을 Todo List 재구현 예시를 통해 상세하게 설명했습니다. 마지막으로 useReduceruseContext를 결합하여 전역 상태 관리를 구현하는 패턴까지 살펴보았습니다.

이제 여러분은 useState만으로는 부족했던 상황에서 useReducer를 사용하여 더 예측 가능하고 유지보수하기 쉬운 상태 관리 코드를 작성할 수 있게 되었습니다. 다음 장에서는 useRef 훅을 이용하여 DOM 요소에 직접 접근하는 방법과 그 활용 사례에 대해 알아보겠습니다.