[React] useState vs useReducer

alswjd·2025년 3월 23일

React Hooks 탐험기

목록 보기
4/5
post-thumbnail

⏪ 이전 글 (useReducer)
https://velog.io/@wowalswjd/about-React-useReducer

이전 글에서 useReducer에 대해 살펴보았다.
useReducer는 컴포넌트에 reducer 함수를 추가해 상태를 관리하는 React Hook으로,
useState와 기능이 비슷하다.

이 때, useState 로직을 어떻게 useReducer 로직으로 바꿀 수 있는지,
어느 상황에서 어떤 Hook을 쓰면 좋을지 작성해보고자 한다.

🧾 참고 문서 :
https://ko.react.dev/learn/extracting-state-logic-into-a-reducer

🚚 useState -> useReducer

컴포넌트가 복잡해지면 컴포넌트의 state가 업데이트되는 다양한 경우를 한눈에 파악하기 어려워짐.

예를 들어, 아래의 TaskApp 컴포넌트는 state에 tasks 배열을 보유하고 있고, 세 가지의 이벤트 핸들러와 set function을 사용하여 task를 추가, 제거 및 수정하고 있음.

복잡성은 줄이고 접근성을 높이기 위해서, 컴포넌트 내부에 있는 state 로직을 컴포넌트 외부의 reducer라고 하는 단일 함수로 옮기는 작업을 3단계에 걸쳐서 진행하고자 함.

import { useState } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, setTasks] = useState(initialTasks);

  function handleAddTask(text) {
    setTasks([...tasks, {
      id: nextId++,
      text: text,
      done: false
    }]);
  }

  function handleChangeTask(task) {
    setTasks(tasks.map(t => {
      if (t.id === task.id) {
        return task;
      } else {
        return t;
      }
    }));
  }

  function handleDeleteTask(taskId) {
    setTasks(
      tasks.filter(t => t.id !== taskId)
    );
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask
        onAddTask={handleAddTask}
      />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Visit Kafka Museum', done: true },
  { id: 1, text: 'Watch a puppet show', done: false },
  { id: 2, text: 'Lennon Wall pic', done: false },
];

1. setState -> dispatch 바꾸기

state를 변경하는 구체적인 로직을 숨기고,
어떠한 일을 하는 로직인지에 대한 정보를 한 단어로 지정하기
=> 사용자의 의도를 더 명확하게 설명할 수 있음.

🛑 setState를 사용한 경우

function handleAddTask(text) {
  setTasks([...tasks, {
    id: nextId++,
    text: text,
    done: false
  }]);
}

function handleChangeTask(task) {
  setTasks(tasks.map(t => {
    if (t.id === task.id) {
      return task;
    } else {
      return t;
    }
  }));
}

function handleDeleteTask(taskId) {
  setTasks(
    tasks.filter(t => t.id !== taskId)
  );
}
  • 현재 코드에서는 state를 설정함으로써 무엇을 할 것인지를 구체적으로 명시하고 있음
    -=> state를 설정하여 React에게 무엇을 할 지에 대한 로직(=setState)를 지정하는 것 대신
    사용자가 방금 한 일(=action)을 지정해주어야 함.

  • 위 코드의 setState들을 한 문장으로 정의하면 다음과 같음.

  1. 사용자가 Add를 눌렀을 때 호출되는 handleAddTask(text).
  2. 사용자가 task를 토글하거나 Save를 누르면 호출되는 handleChangeTask(task).
  3. 사용자가 Delete를 누르면 호출되는 handleDeleteTask(taskId).

✅ dispatch를 사용한 경우

function handleAddTask(text) {
  dispatch({
    type: 'added',
    id: nextId++,
    text: text,
  });
}

function handleChangeTask(task) {
  dispatch({
    type: 'changed',
    task: task
  });
}

function handleDeleteTask(taskId) {
  dispatch({
    type: 'deleted',
    id: taskId
  });
}

즉, 이벤트 핸들러를 통해 tasks를 설정하는 대신 task를 추가/변경/삭제하는 action을 전달하는 것임.

  • dispatch로 넘기게 되는 정보(객체)를 action이라고 함.
  • action은 객체 형태로, 안에 어떤 속성을 추가해도 상관없으나 type 속성은 반드시 포함해야 함
    • type 속성은 어떤 상황이 발생하는지에 대한 최소한의 정보만 명시해야 함.

2. reducer 함수 작성하기

reducer 함수는 1단계에서 생략했었던 state에 대한 구체적인 로직을 담는 곳.

function yourReducer(state, action) {
  // React가 설정하게될 다음 state 값을 반환합니다.
}
  • 매개변수: 현재 state 값과 action 객체
  • 반환값: 다음 state

🛑 setState를 이용한 경우

function handleAddTask(text) {
  setTasks([...tasks, {
    id: nextId++,
    text: text,
    done: false
  }]);
}

function handleChangeTask(task) {
  setTasks(tasks.map(t => {
    if (t.id === task.id) {
      return task;
    } else {
      return t;
    }
  }));
}

function handleDeleteTask(taskId) {
  setTasks(
    tasks.filter(t => t.id !== taskId)
  );
}

✅ reducer를 이용한 경우

위 TaskApp 컴포넌트에 필요한 reducer 함수를 작성해보면 아래와 같음.

function tasksReducer(tasks, action) {
  if (action.type === 'added') {
    return [...tasks, {
      id: action.id,
      text: action.text,
      done: false
    }];
  } else if (action.type === 'changed') {
    return tasks.map(t => {
      if (t.id === action.task.id) {
        return action.task;
      } else {
        return t;
      }
    });
  } else if (action.type === 'deleted') {
    return tasks.filter(t => t.id !== action.id);
  } else {
    throw Error('Unknown action: ' + action.type);
  }
}
  • reducer 함수는 state(tasks)를 인자로 받고 있기 때문에, reducer 함수를 컴포넌트 외부에 작성 가능함.
    • 이렇게 하면 들여쓰기 수준이 줄어들고 state 로직을 한 번에 모아놓을 수 있어 가독성이 좋아짐.
  • if/else문도 사용 가능하지만, 가독성으로 인해 switch문을 사용하는 것이 일반적.
    • Tip 1) 각자 다른 case 속에서 선언된 변수들이 서로 충돌하지 않도록 case 블록을 {}(중괄호)로 감싸기
    • Tip 2) 일반적인 경우라면 case문을 return으로 끝내기
    • Tip 3) 실수 방지 위해 throw문 추가하기

3. 컴포넌트에서 reducer 사용하기

1) 만든 reducer 함수를 import하기

import { useReducer } from 'react';

2) useState 제거하기

const [tasks, setTasks] = useState(initialTasks);

3) 앞서 만든 reducer를 추가해 useReducer 사용하기

const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

✅ 전체 수정 코드

function tasksReducer(tasks, action) {
  switch (action.type) {
    case 'added': {
      return [...tasks, {
        id: action.id,
        text: action.text,
        done: false
      }];
    }
    case 'changed': {
      return tasks.map(t => {
        if (t.id === action.task.id) {
          return action.task;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return tasks.filter(t => t.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}
import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(
    tasksReducer,
    initialTasks
  );

  function handleAddTask(text) {
    dispatch({
      type: 'added',
      id: nextId++,
      text: text,
    });
  }

  function handleChangeTask(task) {
    dispatch({
      type: 'changed',
      task: task
    });
  }

  function handleDeleteTask(taskId) {
    dispatch({
      type: 'deleted',
      id: taskId
    });
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask
        onAddTask={handleAddTask}
      />
      <TaskList
        tasks={tasks}
        onChangeTask={handleChangeTask}
        onDeleteTask={handleDeleteTask}
      />
    </>
  );
}

let nextId = 3;
const initialTasks = [
  { id: 0, text: 'Visit Kafka Museum', done: true },
  { id: 1, text: 'Watch a puppet show', done: false },
  { id: 2, text: 'Lennon Wall pic', done: false }
];

📌 reducer 잘 작성하는 법

1. reducer는 반드시 순수 함수로

state 업데이트 함수와 비슷하게, reducer는 렌더링 중에 실행되기 때문.
(action은 다음 렌더링까지 대기함.)

순수 함수란?

  • 입력 값이 같다면 결과 값도 같아야 함.
  • side effect (ex. 서버 요청, timeout 등)를 수행해서는 안됨
    • 즉, 컴포넌트 외부와 연결하면 의도한 값이 나오지 않을 때도 있으므로
      예측 가능한 값이어야 함.
    • 객체와 배열의 경우 값을 변경하지 말고 새로운 값으로 업데이트해야 함.

2. 각 action은 하나의 사용자 동작만 설명

예를 들어, 사용자가 작성하는 양식에 5개의 필드(name, email 등)가 있다고 가정할 때,

  • 🛑 여러 개의 set_field 액션(ex. setName, setEmail 등)보다는
  • ✅ 하나의 reset_form 액션 보내기
    • 훨씬 간결하며, 디버깅하기 쉬움

✅ useState vs useReducer

  • reducer 함수가 반드시 좋은 것만은 아님.
  • 상황에 맞게 상태 관리 Hook을 선택해 사용하면 됨.

1. 코드 크기

😇 useState < useReducer

  • useState의 경우 미리 작성해야 하는 코드가 없음.
  • 여러 이벤트 핸들러에서 비슷한 방식으로 state 업데이트하는 경우에는 useReducer가 오히려 코드의 양을 줄일 수 있음.

2. 가독성

😇 useState > useReducer

  • 간단한 상태 관리(일반적)의 경우 useState 추천
  • 복잡한 상태 관리의 경우 useReducer를 사용하면
    state 업데이트 로직(=reducer)을 한꺼번에 모아서 볼 수 있고,
    어떤 사용자 동작(=action)이 발생했는지를 한 단어로 볼 수 있어 유리함.

3. 디버깅

😇 useState < useReducer

  • useState는 문제 위치와 원인을 찾기 어려움
  • useReducer를 사용하면 문제 원인 빠르게 파악 가능
    • ex) state 업데이트되는 모든 부분(=reducer)과 actionconsole.log() 찍어보기

4. 테스팅

😇 useState < useReducer

  • reducer는 컴포넌트에 의존하지 않는 순수 함수이므로
    항상 예측 가능한 값을 뱉기 때문에 테스팅에 훨씬 유리함.

5. 개인적인 취향

😇 어느 것을 사용해도 무방함.

  • useStateuseReducer는 동일한 방식이기 때문에
    언제나 마음대로 바꿔서 사용해도 무방함.
profile
와우 프론트엔드

0개의 댓글