리액트 useReducer vs useState: 언제 쓰고 어떻게 마이그레이션할까

리액트 useReducer vs useState: 언제 쓰고 어떻게 마이그레이션할까

useState는 가장 단순한 상태 관리 훅이고, useReducer여러 상태가 서로 연관되어 한꺼번에 업데이트되거나, 업데이트 로직이 분기·규칙으로 복잡할 때 유리합니다. 이 글에서는 선택 기준과 마이그레이션 방법을 간단한 예제로 알아봅니다.

1) 언제 useState, 언제 useReducer?

  • useState: 독립적이고 단순한 값, 이벤트 당 1~2개의 필드만 변경, 로직이 짧음.
  • useReducer: 여러 필드가 동시에 바뀜, 유효성/분기/리셋 규칙이 많음, 액션 로그/테스트가 필요함.
  • 성능은 도구 선택 자체보다 업데이트 설계의 영향이 큼. 복잡도 ↑ → reducer 고려.

2) 마이그레이션 기본 원칙

  1. 현재의 setState 호출들을 “의미 있는 동사(액션)”로 이름 붙인다. (increment, changeField, reset 등)
  2. 초기 상태 객체를 만든다. (initialState)
  3. 액션을 해석하는 reducer(state, action)를 작성하고, useReducer(reducer, initialState)로 교체한다.
  4. 기존 이벤트 핸들러에서 dispatch({ type: '...' , payload }) 호출로 바꾼다.

예제 1 — 카운터(단순 useState → 규칙 많은 useReducer)

처음엔 useState로 충분했지만, “스텝 변경, 최대값 제한, 리셋” 규칙이 늘어나면 useReducer가 더 깔끔해집니다.

✅ 최종 버전: useReducer로 리팩터링

import React, { useReducer } from 'react';

const initialState = { value: 0, step: 1, max: 10 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment': {
      const next = state.value + state.step;
      return { ...state, value: Math.min(next, state.max) };
    }
    case 'decrement': {
      const next = state.value - state.step;
      return { ...state, value: Math.max(next, 0) };
    }
    case 'changeStep': {
      const step = Number(action.payload) || 1;
      return { ...state, step };
    }
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

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

  return (
    <section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:420}}>
      <h3>CounterWithReducer (useReducer)</h3>
      <p>값: <strong>{state.value}</strong> / 최대 {state.max}</p>

      <div style={{display:'flex', gap:8, margin:'8px 0'}}>
        <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
        <button onClick={() => dispatch({ type: 'increment' })}>+</button>
        <button onClick={() => dispatch({ type: 'reset' })}>리셋</button>
      </div>

      <label>스텝 변경</label>
      <input
        type="number"
        value={state.step}
        onChange={(e) => dispatch({ type: 'changeStep', payload: e.target.value })}
        style={{width:100, marginLeft:8}}
      />
    </section>
  );
}

▼ 실행 화면 설명: +/− 버튼으로 값이 스텝 단위로 변하고, 최대값을 넘지 않습니다. 스텝을 바꾸면 즉시 반영되며, “리셋”으로 초기 상태로 돌아갑니다. 규칙이 늘어도 로직이 reducer 하나로 모여 관리하기 쉽습니다.

카운터(단순 useState → 규칙 많은 useReducer) 예제 실행 결과 화면

예제 2 — 다중 필드 폼: useReducer로 일관 업데이트 & 리셋

이름/이메일/비밀번호처럼 여러 필드를 다루고, 필드별 에러/리셋/제출 처리까지 필요할 때 useReducer가 편합니다.

import React, { useReducer } from 'react';

const initialForm = {
  name: '',
  email: '',
  password: '',
  errors: {},
  submitted: false,
};

function validate({ name, email, password }) {
  const errors = {};
  if (!name.trim()) errors.name = '이름을 입력하세요';
  if (!email.includes('@')) errors.email = '이메일 형식이 올바르지 않습니다';
  if (password.length < 6) errors.password = '비밀번호는 6자 이상';
  return errors;
}

function reducer(state, action) {
  switch (action.type) {
    case 'change': {
      const { field, value } = action.payload;
      return { ...state, [field]: value, submitted: false };
    }
    case 'reset':
      return initialForm;
    case 'submit': {
      const errors = validate(state);
      if (Object.keys(errors).length > 0) {
        return { ...state, errors, submitted: false };
      }
      return { ...state, errors: {}, submitted: true };
    }
    default:
      return state;
  }
}

export default function SignupFormReducer() {
  const [state, dispatch] = useReducer(reducer, initialForm);
  const onChange = (field) => (e) =>
    dispatch({ type: 'change', payload: { field, value: e.target.value } });

  return (
    <section style={{border:'1px solid #ddd', padding:12, borderRadius:8, maxWidth:520}}>
      <h3>SignupForm (useReducer)</h3>

      <label>이름</label>
      <input value={state.name} onChange={onChange('name')} style={{display:'block', width:'100%', marginBottom:4}} />
      <p style={{color:'#b91c1c', minHeight:18}}>{state.errors.name || ''}</p>

      <label>이메일</label>
      <input value={state.email} onChange={onChange('email')} style={{display:'block', width:'100%', marginBottom:4}} />
      <p style={{color:'#b91c1c', minHeight:18}}>{state.errors.email || ''}</p>

      <label>비밀번호</label>
      <input type="password" value={state.password} onChange={onChange('password')} style={{display:'block', width:'100%', marginBottom:4}} />
      <p style={{color:'#b91c1c', minHeight:18}}>{state.errors.password || ''}</p>

      <div style={{display:'flex', gap:8}}>
        <button onClick={() => dispatch({ type: 'submit' })}>제출</button>
        <button onClick={() => dispatch({ type: 'reset' })}>리셋</button>
      </div>

      {state.submitted && (
        <p style={{color:'#065f46', marginTop:8}}>제출 성공! 🎉</p>
      )}
    </section>
  );
}

▼ 실행 화면 설명: 제출 시 유효성 검사 결과가 각 필드 아래 표시됩니다. 값/에러/제출 상태가 한 리듀서로 관리되어, 리셋·제출 흐름을 일관되게 제어할 수 있습니다.

다중 필드 폼: useReducer로 일관 업데이트 & 리셋 예제 코드 실행 결과 화면

3) 체크리스트 — useReducer로 갈아탈 때

  • 이벤트 하나가 여러 필드를 동시에 바꾸는가?
  • 업데이트 규칙(분기/최소·최대/유효성)이 점점 늘고 있는가?
  • “리셋” 같은 공통 액션이 자주 필요한가?
  • 디버깅/테스트를 위해 액션 단위 로그/추적이 유용한가?

4) 자주 하는 실수 & 베스트 프랙티스

  • 액션명은 동사형으로: changeField, submit, reset 등 의미가 분명하게.
  • 불변성 유지: {...state}로 새 객체를 반환(직접 변이 금지).
  • 초기 상태 상수화: initialState/initialForm를 모듈 상단에.
  • 로직 분리: 유효성/파생 계산은 별도 함수(예: validate)로 분리해 테스트 용이.
  • 컨텍스트와 결합: 전역적 공유가 필요하면 useReducer + Context 조합 고려.

참고 링크

함께 보면 좋은 게시글

위로 스크롤