리액트로 개발을 진행하다보면 하나의 컴포넌트에서 여러 개의 상태(state)를 사용해야 될 때가 있습니다. 상태가 많아지는 것 자체는 큰 문제가 되지 않을 수도 있지만 상태가 많을 경우 여러 방식으로 상태가 변화할 수 있기 때문에 상태 관리가 복잡해 질 수있습니다. 리액트에서는 이 문제를 해결하기 위해 Reducer를 사용할 수 있습니다.

예시 코드

만약 목록들을 생성, 수정, 삭제하는 기능을 가진 컴포넌트를 만들어야 한다고 가정해봅시다. 그러면 해당 요구사항을 충족하기 위해 다음과 같은 컴포넌트를 만들 수 있습니다.

import { useState } from 'react';

import AddElement from "./AddElement"
import List from "./List"

export default function ListComponent() {
  const [list, setList] = useState(initialTasks);

  function handleAddElement(text) {
    setList([...list, {
      id: nextId++,
      text: text,
    }]);
  }

  function handleChangeElement(element) {
    setList(list.map(e => {
      if (e.id === element.id) {
        return element;
      } else {
        return e;
      }
    }));
  }

  function handleDeleteElement(elementId) {
    setList(
      tasks.filter(e => e.id !== elementId)
    );
  }

  return (
    <>
      <AddElement
        onAddTask={handleAddTask}
      />
      <List
        list={list}
        onChangeElement={handleChangeElement}
        onDeleteElement={handleDeleteElement}
      />
    </>
  );
}

let nextId = 3;
const initialList = [
  { id: 0, text: "목록 1" },
  { id: 1, text: "목록 2" },
  { id: 2, text: "목록 3" },
];

각 이벤트 핸들러(handle...)는 기능을 수행하기 위해 setList를 호출합니다. 이 작은 컴포넌트에서 그렇게 불합리해보이지 않을 수 있지만 컴포넌트가 커질수록 그 안에서 state를 다루는 로직의 양도 늘어나게 됩니다. 여기서 Reducer를 사용한다면 컴포넌트 내부에 있는 state와 관련된 로직을 컴포넌트 외부에 존재하는 단일 함수로 옮길 수 있습니다.

Reducer를 사용하기

useState는 상태를 손으로 직접 관리하는 것과 같고 useReducer는 프린터에 외주 맡기는 것과 같습니다. 물론 프린터는 직접 만들어야 됩니다. 출처: Copilot

먼저 코드에 존재하는 각각의 이벤트 핸들러 대신 "action"을 정의합니다. action은 유저가 한 일을 전달하는 역할을 합니다. 유저와의 상호작용을 action으로 정의하고 해당 action에 대해 어떤 일을 할 것인지 정하는 것이죠. 그래서 action은 꼭 하나의 사용자 상호작용을 설명할 수 있어야 합니다. 일반적으로 action 객체 중 type는 사용자가 어떤 행동을 하였는지 구분하는 역할을 합니다. 어떤 형태든 될 수 있지만 유저의 행위를 쉽게 구분하는 것이 목적이므로 보통 문자열로 작성합니다.

action을 어떻게 정의할지 정하였다면 reducer 함수를 만들 차례입니다. 일반적으로 reducer 함수는 action의 type를 보고 어떤 상호작용이 일어났는지 판단합니다. 그리고 기능을 수행하기 위해 필요한 데이터가 더 존재한다면 자유롭게 추가할 수 있습니다. 공식문서에서는 가독성을 위해 if문이 아닌 switch문을 사용하고 각자 다른 case 속에서 선언된 변수들이 서로 충돌하지 않도록 case 블록을 중괄호로 감싸는 걸 추천합니다.

function listReducer(list, action) {
  switch (action.type) {
    case 'added': {
      return [...list, {
        id: action.id,
        text: action.text,
      }];
    }
    case 'changed': {
      return list.map(e => {
        if (e.id === action.element.id) {
          return action.element;
        } else {
          return t;
        }
      });
    }
    case 'deleted': {
      return list.filter(e => e.id !== action.id);
    }
    default: {
      throw Error('Unknown action: ' + action.type);
    }
  }
}

reducer 함수가 완성되었다면 이 reducer함수를 컴포넌트와 연결하여 사용할 수 있습니다. useReducer를 import하여 useState를 useReducer로 바꾸면됩니다.

import { useReducer } from 'react';

import listReducer from "./listReducer"
import AddElement from "./AddElement"
import List from "./List"

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

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

 function handleChangeElement(element) {
    dispatch({
      type: 'changed',
      element: element
    });
  }

  function handleDeleteElement(elementId) {
    dispatch({
      type: 'deleted',
      id: ListId
    });
  }

  return (
    <>
      <AddElement
        onAddTask={handleAddTask}
      />
      <List
        list={list}
        onChangeElement={handleChangeElement}
        onDeleteElement={handleDeleteElement}
      />
    </>
  );
}

let nextId = 3;
const initialList = [
  { id: 0, text: "목록 1" },
  { id: 1, text: "목록 2" },
  { id: 2, text: "목록 3" },
];

Recucer는 컴포넌트와 같은 파일에 둘 수 있고 예시 코드처럼 다른 파일로 분리하여 사용할 수도 있습니다.

useState와 useReducer

처음에 언급한 것처럼 useReducer는 상태를 관리하기 복잡해질 때 하나의 해결책으로 사용할 수 있습니다. useState와 비교했을 때 몇몇 장단점이 존재하므로 다음의 상황들을 고려하여 어떤 것을 사용할지 결정할 수 있습니다.

상태 관리의 복잡성

상태 관리가 복잡하지 않은 경우 미리 작성해야 될 코드가 적은 useState가 유리할 수 있습니다. 하지만 여러 이벤트 핸들러에서 비슷한 방식으로 상태를 업데이트하는 경우, useReducer를 사용하면 코드의 양을 줄이는 데 도움이 될 수 있습니다.

코드의 양이 줄어들 문 아니라 어떤 상황에서 상태에 어떻게 변하는지 한눈에 알아보기도 편해집니다. 만약 상태의 수가 적다면 각각의 이벤트 핸들러를 직접 살펴보는 것이 간단한 일입니다. 하지만 많은 수의 상태를 가지고 상태의 업데이트가 여러 방식으로 이루어진다면 이벤트 핸들러를 하나씩 살펴보는 것은 복잡해집니다. 이때 action과 dispatch가 분리된 Reducer를 사용한다면 업데이트 로직이 어떻게 동작하는지와 이벤트 핸들러를 통해서 어떤 이벤트가 발생했는지에 대한 관심사를 명확하게 구분할 수 있습니다.

디버깅과 테스팅

useReducer를 사용하면, console.log를 reducer에 추가하여 state가 업데이트되는 모든 부분과 왜 해당 버그가 발생했는지(어떤 action으로 인한 것인지)를 확인할 수 있습니다. 물론 상태가 간단한 경우 useState를 사용하여도 각각의 이벤트 핸들러에 console.log를 추가할 수 있지만 reducer함수 안에 사용하는 것이 훨씬 간단합니다.

reducer는 컴포넌트에 의존하지 않는 순수 함수입니다. 즉 위의 예시 코드처럼 다른 파일을 사용한다면 jest나 vitest와 같은 테스트 라이브러리를 이용하여 테스트하기 더 쉽다는 말입니다. 일반적으로 더 현실적인 환경에서 컴포넌트를 테스트하는 것이 좋지만, 복잡한 상태를 업데이트하는 로직의 경우 reducer는 정상적으로 작동한다고(상태는 올바르게 관리되었다고) 가정하고 테스트할 필요가 있을 수도 있습니다. 이 경우 useReducer를 사용하는 것이 좋습니다.

사실 useState나 useRducer는 같습니다.

공식문서에서는 일부 컴포넌트에서 잘못된 방식으로 state를 업데이트하는 것으로 인한 버그가 자주 발생하거나 해당 코드에 더 많은 구조를 도입하고 싶다면 reducer 사용을 권장합니다. 그러나 동시에 reducer를 적용할 필요는 없다고 권장합니다. 하나의 컴포넌트에서도 일부 상태는 useState를 그대로 남겨두고 다른 부분에만 useReducer로 변경할 수 있습니다. 결국 상태 관리를 위해 어떤 방식을 사용할지는 개발자의 선호도와 컴포넌트의 복잡성에 달렸습니다.

회원가입 폼에서 각각의 input을 useState로 작성할 수도 있지만 다음과 같이 reducer를 통해 작성하여도 무방합니다.

function reducer(
    state: typeof initialState,
    action: Action,
) {
    switch (action.type) {
        case "userId":
            return { ...state, account: action.payload };
        case "password":
            return { ...state, password: action.payload };
        case "passwordCheck":
            return { ...state, password2: action.payload };
        case "nickname":
            return { ...state, userName: action.payload };
        // ...
        default:
            return state;
    }
};

만약 초기 값이 동적(함수)이라면?

만약 useReducer의 초기 상태가 동적이어서 특정 함수의 실행 결과로 정해져야 한다면 useState처럼 함수의 포인터를 인자로 넣는 것이 더욱 좋습니다. 그러면 useReducer의 인자로 들어간 함수가 매번 실행되지 않고 최초로 렌더링되었을 경우만 실행되고 이후에는 무시되기 때문에 성능을 개선할 수 있습니다.

function createInitialState(username) {
  // ...
}

function Component({ username }) {
  const [state, dispatch] = useReducer(reducer, username, createInitialState);
  // ...

만약 createInitialState가 인자를 받지 않는다면 useReducer의 두 번째 인자를 null로 설정할 수 있습니다.

참고 자료

React, useReducer, state 로직을 reducer로 작성하기

+ Recent posts