본문 바로가기
Front-End/React + Native

[React]useState vs. useReducer

by MS_developer 2025. 2. 1.

이전의 포스팅에서는 효율적인 애플리케이션 구동과 프로젝트 관리 등의 이유로 상태관리를 "잘" 해야된다고 했었는데, 이 주제와 관련해서 리액트에서 대표적으로 사용되는 상태 관리 훅(hook) 두 가지에 대해 알아보고자 한다.

 

Generated by Napkin AI

 

바로 useStateuseReducer이다.

 

보편적으로, useState는 단순한 상태 관리를 사용할 때 좋고 useReducer는 복잡한 상태 관리를 필요할 때 사용하는 것이 좋다고 알고 있다.

 

하지만 실무에서는 useState가 익숙하고 간단하다보니 useReducer를 제대로 사용하는 경우가 매우 드물다. 

 

useState와 useReducer 훅들은 정확히 어떤 차이가 있고, 어떤 상황에 따라 알맞는 훅을 사용하는 것이 좋을지 다시 한 번 자세하게 알아보자.

 


useState

useState는 컴포넌트 내부에서 간단한 상태를 관리하는 React 내장 훅이다.

 

const [state, setState] = useState(initialState);

 

리액트를 접했다면 가장 먼저 접하는 기본적인 개념 중 하나로, 상태 값과 상태를 변경하는 setter 함수를 반환한다.

 

초기 상태(initial state)를 설정할 수 있고, 상태 값을 직접적으로 변경할 수 있다. 또한, 상태가 변경되면 컴포넌트가 리렌더링되어 동적 컴포넌트 렌더링을 활용한 다양한 기능을 손쉽게 구현할 수 있다. 

 

가장 대표적으로 사용되는 예시를 통해 useState의 장단점을 알아보자.

 

useState의 장점

 

import React, { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
      <button onClick={() => setCount(count - 1)}>감소</button>
    </div>
  );
};

export default Counter;

 

useState는 위 코드처럼 간단한 상태 관리, 즉 작은 범위의 상태를 쉽게 다룰 수 있다.

 

직관적이기 때문에 학습 곡선(Learning curve)가 낮은 편이고, 입력 폼, 토글 버튼, 카운터 등과 같은 UI 상태 관리에 적합하다.

 

또한 useState는 React.memo, useCallback, useMemo를 활용한 다양한 최적화 기법을 적용하여 성능을 높일 수 있다.

 

위의 예시 코드를 사용해 다음과 같이 적용하여 최적화할 수도 있다.

 

import React, { useState, useCallback } from "react";

// 자식 컴포넌트 (React.memo를 사용하여 최적화)
const Child = React.memo(({ onIncrement }) => {
  console.log("Child 렌더링");
  return <button onClick={onIncrement}>증가</button>;
});

const Parent = () => {
  const [count, setCount] = useState(0);

  // useCallback을 사용하여 함수를 메모이제이션
  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);

  console.log("Parent 렌더링");
  return (
    <div>
      <p>카운트: {count}</p>
      <Child onIncrement={increment} />
    </div>
  );
};

export default Parent;

 

만약 위 코드에서 React.memo와 useCallback 함수를 사용하지 않는다 가정해보자.

 

setCount를 호출하면 Child 컴포넌트는 props도 상태도 변경되지 않았지만 불필요한 리렌더링 과정을 거쳐야 한다. 즉, Child 컴포넌트의 UI는 변경된 점이 없지만 다시 렌더링이 되기 때문에 성능이 낭비되고 있다. 

 

예시 코드에서는 매우 간단한 내용만이 포함되어 있지만, 만약 Child 컴포넌트가 보다 복잡하고 다양한 로직을 가진 컴포넌트라면 불필요한 리렌더링에 따라 큰 성능 차이가 발생할 수 있다. 이럴 경우 useState는 React.memo와 useCallback을 사용하여 성능을 개선하는 방법을 고려할 수 있는 것이다.

 

useState의 단점

 

그렇다면 useState의 단점은 무엇이 있을까?

 

먼저 상태 업데이트가 비동기적으로 이루어지기 때문에 setState 호출 직후 변경된 값을 바로 참조할 수 없다

 

import React, { useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    console.log("현재 카운트 값:", count); // 🚨 count 값이 즉시 업데이트되지 않음
  };

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={handleClick}>증가</button>
    </div>
  );
};

export default Counter;

 

위와 같이 console.log를 통해 참조할 카운트 값은 변경되지 않았기에 개발 과정에서 혼동을 줄 수 있다.

 

또한 useState는 단순한 상태 관리에 사용되는 경우가 많다보니 서로 연관이 있는 여러 상태를 업데이트할 경우 useState를 반복적으로 사용하여 코드의 복잡도가 높아지고 유지보수도 어려워진다.

 

앞서 언급했듯, setState가 호출될 때마다 컴포넌트가 리렌더링 되기 때문에 동일한 상태를 다시 설정해도 불필요한 렌더링이 발생할 수 있다. 

 

const Component = () => {
  const [count, setCount] = useState(0);

  console.log("컴포넌트 렌더링됨");

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(0)}>0으로 설정</button>
    </div>
  );
};

 

 

위 코드에서 count가 0일 때 "0으로 설정" 버튼을 눌러도 리렌더링이 발생하는 것과 같은 경우에도 성능 저하를 유발한다.


 

useReducer

앞서 언급된 내용들을 곱씹어보면서 useReducer의 특징과 장단점을 알아보자.

 

useReducer는 복잡한 상태 로직을 함수 기반으로 관리하는 React 훅으로, 상태(state)와 액션(action)을 기반으로 상태를 변경하는 reducer 함수를 정의해 사용한다.

 

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

 

먼저 상태를 변경하는 로직을 reducer 함수에 정의하고, 액션을 기반으로 상태를 변경한다( dispatch(action) ).

 

useState와 같이 예시 코드를 통해 useReducer의 장단점을 알아보자.

 

useReducer의 단점

 

import React, { useReducer } from "react";

const initialState = { count: 0 };

const reducer = (state, action) => {
  switch (action.type) {
    case "INCREMENT":
      return { count: state.count + 1 };
    case "DECREMENT":
      return { count: state.count - 1 };
    case "RESET":
      return initialState;
    default:
      throw new Error("알 수 없는 액션 타입");
  }
};

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

  return (
    <div>
      <p>카운트: {state.count}</p>
      <button onClick={() => dispatch({ type: "INCREMENT" })}>증가</button>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>감소</button>
      <button onClick={() => dispatch({ type: "RESET" })}>초기화</button>
    </div>
  );
};

export default Counter;

 

 

위 코드를 보았을 때, useReducer의 장점보다는 단점이 부각된다고 생각한다.

 

먼저 코드가 길어졌다. 어찌보면 당연할 수 있지만, 복잡한 상태 관리에 따라 reducer 함수, dispatch 함수 호출 등으로 코드량이 증가한다.

 

또한 useState에 비해 상대적으로 초기 학습이 어려울 수 있다. Redux와 유사한 개념을 이해해야되기 때문에 리액트에 입문하는 초보자에게는 단번에 이해하기에는 복잡한 개념이라고 생각한다.

 

따라서 단순한 상태 관리에 굳이 useReducer를 사용하는 것은 오히려 비효율적이다.

 

useReducer의 장점

 

앞서 언급했던 useState의 단점들을 떠올려보며 위의 예시 코드를 다시 살펴보자.

 

복잡한 상태 변경 로직을 한 곳에서 관리가 가능하다. reducer 함수를 통해 일관성을 유지할 수 있기 때문에 상태 변경 로직이 예측 가능하도록 체계적으로 관리할 수 있다. 자연스럽게, 그리고 어찌보면 당연하게, 코드의 유지보수가 용이하다.

 

두 코드를 통해 비교해보도록 하자.

 

import React, { useState } from "react";

const Profile = () => {
  const [name, setName] = useState("");
  const [age, setAge] = useState(0);
  const [email, setEmail] = useState("");
  const [isEditing, setIsEditing] = useState(false);

  const handleNameChange = (e) => setName(e.target.value);
  const handleAgeChange = (e) => setAge(e.target.value);
  const handleEmailChange = (e) => setEmail(e.target.value);
  const toggleEdit = () => setIsEditing(!isEditing);

  return (
    <div>
      <input type="text" value={name} onChange={handleNameChange} />
      <input type="number" value={age} onChange={handleAgeChange} />
      <input type="email" value={email} onChange={handleEmailChange} />
      <button onClick={toggleEdit}>
        {isEditing ? "수정 완료" : "수정하기"}
      </button>
    </div>
  );
};

 

 

위 예시 코드는 useState를 여러 번 사용하여 일관성이 유지되지 않고 있다.

 

상태 변경 함수들이 여러 개로 분산되어 있고, 새로운 상태가 추가될 경우 useState에 대한 새로운 setState를 함수를 추가해야 한다. 관리해야할 상태들이 많아짐에 따라 서로에 대한 연관성도 유지하기 어려워질 것이다.

 

import React, { useReducer } from "react";

const initialState = {
  name: "",
  age: 0,
  email: "",
  isEditing: false,
};

const reducer = (state, action) => {
  switch (action.type) {
    case "SET_NAME":
      return { ...state, name: action.payload };
    case "SET_AGE":
      return { ...state, age: action.payload };
    case "SET_EMAIL":
      return { ...state, email: action.payload };
    case "TOGGLE_EDIT":
      return { ...state, isEditing: !state.isEditing };
    default:
      return state;
  }
};

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

  return (
    <div>
      <input
        type="text"
        value={state.name}
        onChange={(e) => dispatch({ type: "SET_NAME", payload: e.target.value })}
      />
      <input
        type="number"
        value={state.age}
        onChange={(e) => dispatch({ type: "SET_AGE", payload: e.target.value })}
      />
      <input
        type="email"
        value={state.email}
        onChange={(e) => dispatch({ type: "SET_EMAIL", payload: e.target.value })}
      />
      <button onClick={() => dispatch({ type: "TOGGLE_EDIT" })}>
        {state.isEditing ? "수정 완료" : "수정하기"}
      </button>
    </div>
  );
};

export default Profile;

 

useReducer를 사용했을 때, 코드는 더 길어졌지만 코드의 가독성이 높아져 상태들 간의 연관성을 유추하기 쉽고, 유비보수에도 상태 관리가 필요한 곳이 더욱 명확해졌다. 또한 상태를 추가할 때 확장성이 더 좋기 때문에 기존의 useState 코드보다 부담이 덜 하다.

 

TypeScript를 사용하는 관점에서 보았을 때 initialState에 대한 타입만을 지정하면 되기 때문에 타입 관리도 더욱 용이하다.

 

또한, dispatch를 이용한 액션 기반 업데이트 덕분에 앞서 언급했던 useState에서 상태가 변하지 않았음에도 리렌더링이 발생하는 불필요한 렌더링도 방지할 수 있다.


그럼에도 useReducer?

 

useState와 useReducer의 장단점을 비교했을 때, 한 가지 의문이 드는 점이 있었다.

 

useState에서 최적화 기법을 통해 불필요한 렌더링을 방지할 수 있는데,
그렇다면 굳이 useReducer를 사용해야 할 필요가 있는가?

 

 

useReducer는 단순히 불필요한 리렌더링을 방지하고자 쓰는 것만은 아니기에 상태 변경 로직을 한 곳에서 관리할 수 있는지, 상태들을 일괄적으로 처리하여 코드의 가독성을 높일 수 있는지, 이후 보다 다양한 상태들을 추가할 수 있는 가능성을 고려해 확장성이 좋은 설계가 필요한지 등을 고려하여 사용하는 것이 좋다.

 

즉, "최적화 기능이 있으니 useState만 쓰면 되지 않나?" 보다는 "상태 로직의 복잡성에 따라 적절한 도구를 선택해야 한다"가 useReducer 훅을 고려할 때 올바른 접근이다.


언제 useState를 쓰고, 언제 useReducer를 써야 할까?

 

먼저 useState와 useReducer를 비교하여 표로 정리해보았다.

 

비교 항목 useState useReducer
사용 목적 단순한 상태 관리 복잡한 상태 로직 처리
업데이트 방식 setter 함수 사용 dispatch 함수 사용
리렌더링 상태 변경 시 즉시 리렌더링 상태 변경 시 리렌더링 (불필요한 업데이트 방지)
코드 복잡도 간단하고 직관적 상태 변경 로직이 길어질 수 있음
주요 활용 사례 폼 입력, 단순 카운터, UI 토글 상태 여러 상태를 조작하는 복잡한 로직,
상태 변경 패턴이 명확한 경우

 

 

위 표를 기반으로 useReducer를 써야할 때와 안 써야할 때를 구분해보자.

 

상황 적합한 훅
단순한 상태(숫자, 문자열, 불리언) 관리 useState
한 상태의 변경이 다른 상태에 영향을 주지 않음 useState
상태가 복잡하고, 여러 개의 관련된 값을 함께 관리해야 함 useReducer
상태 변경 로직이 복잡하여 if-else나 switch 문이 많아지는 경우 useReducer
액션을 기반으로 상태를 변경하는 패턴이 필요함 useReducer
상태 변경이 자주 발생하지만, 불필요한 리렌더링을 최소화하고 싶음 useReducer

 

위 표처럼 구현해야할 기능과 상황에 따라 알맞는 훅을 사용하는 것이 가장 중요하다.


참조

댓글