useReducer: React의 강력한 상태 관리

박영광·2024년 8월 6일
0

React

목록 보기
22/23

useReducer란?

useReducer는 React의 내장 Hook으로, 복잡한 상태 로직을 관리하는 데 사용됩니다. 이는 Redux의 핵심 개념인 리듀서(reducer)에서 영감을 받았습니다.

리듀서는 다음과 같은 형태를 가진 순수 함수입니다:

(state, action) => newState

여기서:

  • state는 현재 상태입니다.
  • action은 상태를 어떻게 변경할지 지시하는 객체입니다.
  • newState는 리듀서가 계산한 새로운 상태입니다.

useReducer의 작동 원리

  1. 초기 상태를 설정합니다.
  2. 리듀서 함수를 정의합니다.
  3. useReducer Hook을 호출하여 현재 상태와 dispatch 함수를 받습니다.
  4. 컴포넌트 내에서 dispatch 함수를 사용하여 액션을 발생시킵니다.
  5. 리듀서 함수가 액션을 처리하여 새로운 상태를 반환합니다.
  6. React가 새로운 상태로 컴포넌트를 다시 렌더링합니다.

기본 사용법

useReducer의 기본 사용법은 다음과 같습니다:

const [state, dispatch] = useReducer(reducer, initialState);

여기서:

  • reducer: 상태를 업데이트하는 함수입니다.
  • initialState: 초기 상태 값입니다.
  • state: 현재 상태 값입니다.
  • dispatch: 액션을 발생시키는 함수입니다.

카운터 예제

import React, { useReducer } from 'react';

// 액션 타입 상수 정의
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
const RESET = 'RESET';
const SET_VALUE = 'SET_VALUE';

// 리듀서 함수
function counterReducer(state, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    case RESET:
      return { count: 0 };
    case SET_VALUE:
      return { count: action.payload };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: INCREMENT })}>+</button>
      <button onClick={() => dispatch({ type: DECREMENT })}>-</button>
      <button onClick={() => dispatch({ type: RESET })}>Reset</button>
      <button onClick={() => dispatch({ type: SET_VALUE, payload: 10 })}>Set to 10</button>
    </div>
  );
}

이 예제에서:

  • 액션 타입을 상수로 정의하여 타입 안정성을 높였습니다.
  • 리듀서 함수에서 다양한 액션 타입을 처리합니다.
  • SET_VALUE 액션은 payload를 사용하여 카운터 값을 직접 설정할 수 있게 합니다.

useState vs useReducer

useState와 useReducer는 모두 상태 관리를 위한 도구지만, 각각 다른 상황에서 더 적합합니다.

useState

  • 장점:
    • 간단한 상태 관리에 적합
    • 직관적이고 사용하기 쉬움
    • 적은 양의 코드로 구현 가능
  • 단점:
    • 복잡한 상태 로직을 다루기 어려움
    • 여러 상태 값이 연관되어 있을 때 관리가 어려움
  • 적합한 상황:
    • 독립적인 간단한 상태 값들을 관리할 때
    • 상태 업데이트 로직이 간단할 때

useReducer

  • 장점:
    • 복잡한 상태 로직을 체계적으로 관리 가능
    • 관련된 여러 상태를 하나의 객체로 관리 가능
    • 상태 업데이트 로직을 컴포넌트 외부로 분리 가능
  • 단점:
    • 간단한 상태 관리에는 과도한 보일러플레이트 코드가 필요할 수 있음
    • 학습 곡선이 useState보다 가파름
  • 적합한 상황:
    • 여러 하위 값을 포함하는 복잡한 상태 객체를 관리할 때
    • 다음 상태가 이전 상태에 의존적인 경우
    • 상태 변경 로직이 복잡하거나 다양한 경우

예제: useState vs useReducer

import React, { useState, useReducer } from 'react';

// useState를 사용한 카운터
function CounterWithState() {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prevCount => prevCount + 1);
  const decrement = () => setCount(prevCount => prevCount - 1);
  const reset = () => setCount(0);

  return (
    <div>
      <h2>Counter with useState</h2>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

// useReducer를 사용한 카운터
function counterReducer(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:
      return state;
  }
}

function CounterWithReducer() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });

  return (
    <div>
      <h2>Counter with useReducer</h2>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>Decrement</button>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
}

이 예제에서 볼 수 있듯이, 간단한 카운터의 경우 useState가 더 간결하지만, 상태 업데이트 로직이 복잡해지면 useReducer가 더 체계적인 관리를 가능하게 합니다.

복잡한 상태 관리 예제

이제 좀 더 복잡한 예제를 통해 useReducer의 강력함을 살펴보겠습니다. 여기서는 TODO 리스트 애플리케이션을 만들어보겠습니다.

import React, { useReducer, useState } from 'react';

// 액션 타입 정의
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOGGLE_TODO';
const DELETE_TODO = 'DELETE_TODO';
const SET_FILTER = 'SET_FILTER';

// 초기 상태
const initialState = {
  todos: [],
  filter: 'ALL' // 'ALL', 'ACTIVE', 'COMPLETED'
};

// 리듀서 함수
function todoReducer(state, action) {
  switch (action.type) {
    case ADD_TODO:
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.payload, completed: false }]
      };
    case TOGGLE_TODO:
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
        )
      };
    case DELETE_TODO:
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.payload)
      };
    case SET_FILTER:
      return {
        ...state,
        filter: action.payload
      };
    default:
      return state;
  }
}

// 필터링된 할 일 목록을 반환하는 함수
function getFilteredTodos(todos, filter) {
  switch (filter) {
    case 'ACTIVE':
      return todos.filter(todo => !todo.completed);
    case 'COMPLETED':
      return todos.filter(todo => todo.completed);
    default:
      return todos;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  const [text, setText] = useState('');

  const filteredTodos = getFilteredTodos(state.todos, state.filter);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    dispatch({ type: ADD_TODO, payload: text });
    setText('');
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input value={text} onChange={(e) => setText(e.target.value)} />
        <button type="submit">Add Todo</button>
      </form>
      <div>
        <button onClick={() => dispatch({ type: SET_FILTER, payload: 'ALL' })}>All</button>
        <button onClick={() => dispatch({ type: SET_FILTER, payload: 'ACTIVE' })}>Active</button>
        <button onClick={() => dispatch({ type: SET_FILTER, payload: 'COMPLETED' })}>Completed</button>
      </div>
      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <span
              style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
              onClick={() => dispatch({ type: TOGGLE_TODO, payload: todo.id })}
            >
              {todo.text}
            </span>
            <button onClick={() => dispatch({ type: DELETE_TODO, payload: todo.id })}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

이 예제에서 우리는:

  1. 여러 액션 타입을 정의했습니다 (ADD_TODO, TOGGLE_TODO, DELETE_TODO, SET_FILTER).
  2. 이러한 액션들을 처리하는 리듀서 함수를 만들었습니다.
  3. useReducer를 사용하여 todos와 filter 상태를 관리합니다.
  4. 필터링 로직을 별도의 함수로 분리하여 관심사를 분리했습니다.
  5. 다양한 상황에서 dispatch 함수를 호출하여 상태를 업데이트합니다.

이 예제는 useReducer를 사용하여 복잡한 상태 로직을 어떻게 체계적으로 관리할 수 있는지 보여줍니다.

useReducer의 장단점

장점:
1. 예측 가능한 상태 변화: 모든 상태 변화가 리듀서 함수 내에서 명확하게 정의됩니다.
2. 디버깅 용이성: 액션과 그에 따른 상태 변화를 쉽게 추적할 수 있습니다.
3. 로직의 분리: 상태 관리 로직을 컴포넌트에서 분리할 수 있어 코드 구조가 개선됩니다.
4. 테스트 용이성: 리듀서 함수는 순수 함수이므로 테스트하기 쉽습니다.
5. 복잡한 상태 관리: 여러 하위 값을 가진 복잡한 상태를 효과적으로 관리할 수 있습니다.
6. 성능 최적화: 복잡한 상태 업데이트를 최적화하는 데 도움이 됩니다.

단점:
1. 러닝 커브: useState에 비해 개념 이해와 사용법 습득에 시간이 더 걸릴 수 있습니다.
2. 보일러플레이트: 간단한 상태 관리의 경우 useState보다 더 많은 코드가 필요할 수 있습니다.
3. 과도한 추상화: 작은 규모의 애플리케이션에서는 불필요한 복잡성을 야기할 수 있습니다.
4. 액션 타입 관리: 많은 액션 타입을 관리해야 할 경우 코드가 복잡해질 수 있습니다.

실제 프로젝트 적용 팁

  1. 액션 생성자 사용:
    액션 객체 생성을 함수화하여 재사용성을 높이고 타입 안정성을 개선할 수 있습니다.
const addTodo = (text) => ({ type: ADD_TODO, payload: text });
const toggleTodo = (id) => ({ type: TOGGLE_TODO, payload: id });

// 사용
dispatch(addTodo('새로운 할 일'));
dispatch(toggleTodo(1));
  1. 초기 상태를 함수로 제공:
    복잡한 초기 상태의 경우, 함수를 사용하여 초기화할 수 있습니다.
const initializeTodos = () => {
  const localData = localStorage.getItem('todos');
  return localData ? JSON.parse(localData) : [];
};

const [state, dispatch] = useReducer(todoReducer, null, initializeTodos);
  1. useContext와 결합:
    전역 상태 관리를 위해 useReducer와 useContext를 함께 사용할 수 있습니다.
const TodoContext = React.createContext();

const TodoProvider = ({ children }) => {
  const [state, dispatch] = useReducer(todoReducer, initialState);
  return (
    <TodoContext.Provider value={{ state, dispatch }}>
      {children}
    </TodoContext.Provider>
  );
};

// 자식 컴포넌트에서 사용
const { state, dispatch } = useContext(TodoContext);
  1. 타입스크립트 활용:
    타입스크립트를 사용하여 액션과 상태의 타입을 명확히 정의하면 더욱 안정적인 코드를 작성할 수 있습니다.
type State = {
  todos: { id: number; text: string; completed: boolean }[];
  filter: 'ALL' | 'ACTIVE' | 'COMPLETED';
};

type Action =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'TOGGLE_TODO'; payload: number }
  | { type: 'SET_FILTER'; payload: State['filter'] };

const todoReducer = (state: State, action: Action): State => {
  // 리듀서 로직
};
  1. 불변성 유지:
    상태 업데이트 시 불변성을 유지하는 것이 중요합니다. 이를 위해 스프레드 연산자나 immer 라이브러리를 사용할 수 있습니다.
import produce from 'immer';

const todoReducer = produce((draft, action) => {
  switch (action.type) {
    case ADD_TODO:
      draft.todos.push({ id: Date.now(), text: action.payload, completed: false });
      break;
    // 다른 케이스들...
  }
});
  1. 성능 최적화:
    큰 상태 객체를 다룰 때는 useCallback과 useMemo를 활용하여 불필요한 리렌더링을 방지할 수 있습니다.
const memoizedDispatch = useCallback(dispatch, []);
const memoizedState = useMemo(() => computeExpensiveState(state), [state]);
  1. 디버깅 강화:
    개발 중에는 로깅 미들웨어를 추가하여 액션과 상태 변화를 쉽게 추적할 수 있습니다.
const loggerMiddleware = (reducer) => (state, action) => {
  console.log('이전 상태:', state);
  console.log('액션:', action);
  const nextState = reducer(state, action);
  console.log('다음 상태:', nextState);
  return nextState;
};

const [state, dispatch] = useReducer(loggerMiddleware(todoReducer), initialState);

결론

useReducer는 React 애플리케이션에서 복잡한 상태 로직을 관리하는 강력한 도구입니다. 상태 변화의 예측 가능성을 높이고, 로직을 분리하여 코드의 구조를 개선할 수 있습니다. 특히 여러 관련 상태를 함께 관리해야 하거나, 상태 변화 로직이 복잡한 경우에 useReducer의 사용을 고려해보세요.

useReducer는 단순히 상태 관리 도구를 넘어 애플리케이션의 아키텍처를 설계하는 데 도움을 줍니다. 잘 구조화된 리듀서와 액션을 통해 비즈니스 로직을 명확하게 표현할 수 있으며, 이는 코드의 가독성과 유지보수성을 크게 향상시킵니다.

그러나 모든 상황에 useReducer가 적합한 것은 아닙니다. 간단한 상태 관리나 작은 규모의 애플리케이션에서는 useState가 여전히 좋은 선택일 수 있습니다. 프로젝트의 복잡성과 규모, 팀의 숙련도를 고려하여 적절한 상태 관리 전략을 선택하는 것이 중요합니다.

최종적으로, useReducer를 효과적으로 활용하면 React 애플리케이션의 상태 관리를 더욱 체계적이고 확장 가능하게 만들 수 있습니다. 이는 애플리케이션의 성능을 최적화하고, 코드의 구조를 개선하며, 개발 경험을 향상시키는 데 큰 도움이 될 것입니다.


#React #useReducer #Hooks #상태관리 #리액트훅 #프론트엔드개발 #리액트상태관리 #리듀서패턴 #리액트최적화 #자바스크립트

profile
매일 1mm씩 성장하겠습니다

0개의 댓글