useReducer를 언제 쓰는게 좋을까?

se-een·2023년 6월 5일
2

React 탐구하기

목록 보기
3/7
post-thumbnail

React 공식문서에 가보면 State Hooks으로 useStateuseReducer가 있는 것을 확인해볼 수 있습니다.

useReducer도 useState와 동일하게 state를 관리하는 훅입니다.

그런데 보통 useReducer는 React만을 사용할 때는 잘 안 보이는 것 같고, Redux를 이용한 store 상태관리를 진행할 때 자주 보이는 것 같습니다. 이는 리덕스가 추구하는 방향과 사용법이 비슷해서 그런 것 같군요. 🧐

아무튼 이 글에서는 React만을 사용할 때 useReducer는 언제 쓰는 것이 좋을지 한 번 정리해보도록 하겠습니다. 본 글에 들어가기 앞서 useReducer 사용 방법에 대해 간략하게 짚고 넘어가겠습니다.

useReducer 사용 방법

import { useReducer } from 'react';

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREASE':
      return state + 1;
    case 'DECREASE':
      return state - 1;
  }
  throw new Error('[ERROR] unknown action type');
};

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

  return (
    <>
      <h3>{state}</h3>
      <button
        onClick={() => {
          dispatch({ type: 'INCREASE' });
        }}
      >
        Increase Counter
      </button>
      <button
        onClick={() => {
          dispatch({ type: 'DECREASE' });
        }}
      >
        Decrease Counter
      </button>
    </>
  );
}

useReducer로 간단하게 카운터를 만들어보았습니다. useReducer는 다음과 같은 매개변수와 반환값을 순서대로 갖고 있습니다.

매개변수

  • reducer : state 업데이트 방식을 지정하는 순수 함수
  • initialArg : state 초기 값 (모든 데이터 유형 가능)
  • (optional) init : state 초기 값을 지정할 수 있는 초기화 함수.

(optional) init 매개변수 값이 없으면 initialArg의 값으로 state 초기값을, 있으면 초기화 함수를 호출한 결과값으로 state 초기값을 지정합니다.

반환값

  • state : 현재 state 값
  • dispatch : state를 업데이트 하고 리렌더링을 촉발하는 함수

반환값을 배열의 형태를 띄고 있습니다. 따라서 useState와 동일한 방식으로 구조분해할당 하여 사용하는 것이 일반적인 방법입니다.

reducer 함수

useReducer 선언 시 첫 번째 인자로 넘겨주는 reducer 함수는 다음과 같은 매개변수와 반환값을 갖습니다.

매개변수

  • state : 사용하고 있는 state
  • action : dispatch 함수로부터 받을 액션 또는 다음 state 값

인자의 이름은 상관없으나 보통 stateaction으로 사용합니다.

두 번째 파라미터 action 의 경우, Redux의 영향으로 보통 액션 타입을 받는 용도로 많이 사용하시는 것 같습니다만, next State를 받는 용도로도 충분히 사용할 수 있습니다.

반환값

  • 업데이트 한 state

useState의 setter에 작성하는 로직(업데이트 한 state 값)과 동일합니다.

dispatch 함수

useReducer가 반환하는 두 번째 값인 dispatch 함수는 다음과 같은 매개변수를 갖습니다. 반환값은 없습니다.

매개변수

  • action : 수행할 작업 또는 다음 state 값 (모든 데이터 유형 가능)

관용적으로 액션은 보통 type 속성이 있는 객체입니다. 선택적으로 다른 속성을 추가로 포함할 수 있습니다.

위에서 reducer의 두 번째 매개변수를 next State를 받는 용도로 사용하셨다면, 액션 타입을 전달하는 대신 next State 값을 전달하셔야 합니다.

useReducer는 언제 사용하는게 좋을까?

사실 위 카운터 예제는 useReducer 대신 useState를 사용해도 충분히 구현할 수 있습니다. 오히려 useState를 사용하는 것이 reducer 함수를 따로 작성하지 않아도 되고, 전체적인 코드 길이가 더 줄어들 것입니다.

하지만 state가 다음과 같은 형태라면 어떨까요?

state의 형태가 복잡할 때

const state = {
  name : 'Lee',
  age : 20,
  address : {
    country : 'Korea',
    city : 'Seoul'
  },
  whatHave : {
    home : {
      price : 1_000_000,
      address : {
        city : 'Seoul',
        detail : 'Gangnam 23'
      }
    },
    money : 1_000_000
  }
}

위와 같이 state가 복잡한 구조의 객체라고 가정해보겠습니다.

버튼을 누를 때마다 집값 state.whatHave.home.price 이 두 배로 오른다고 합니다. 이를 useState의 setter로 업데이트 해줘야한다면 어떨까요?

다음과 같이 작성해볼 수 있을 것입니다.

setState({
  ...state,
  whatHave: {
    ...state.whatHave,
    home: { ...state.whatHave.home, price: state.whatHave.home.price * 2 },
  },
});

이전 state를 스프레드 연산자를 통해 전개 해줘야하기에 set 하는 로직이 상당히 복잡해지죠.

이런 로직이 컴포넌트 내부 곳곳에 파편화되어 있다고 생각해보세요. 코드 가독성이 떨어지고 파트너가 해당 코드를 잘못 해석할 여지도 있습니다. 해당 state를 자식 컴포넌트에 전달해줘서 프롭스 드릴링까지 발생했다면 정말 끔찍할 것 같네요. 😱

반대로 useReducer를 액션 타입 방식으로 사용한다면 다음과 같이 작성해볼 수 있겠네요.

dispatch({ type : 'INCREASE_HOME_PRICE_DOUBLE' });

기존의 setState 보다 보기 편하지 않나요?

물론 reducer 함수에 useState setter에 작성했던 state 업데이트 로직을 따로 작성해줘야 합니다.

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREASE_HOME_PRICE_DOUBLE':
      return {
        ...state,
        whatHave: {
          ...state.whatHave,
          home: { ...state.whatHave.home, price: state.whatHave.home.price * 2 },
        },
      };
  }
  throw new Error('[ERROR] unknown action type');
};

업데이트 로직을 간단하게 할 수 있는 것은 아니지만, React 컴포넌트 내부에서 복잡한 업데이트 로직을 분리할 수 있다는 것이 장점입니다.

분산된 setState 로직을 한 곳에 모으고 싶을 때

reducer 함수는 컴포넌트 외부로 분리되어 있으며, 모든 setter 로직이 몰려 있기에, state가 잘못 업데이트 되었을 때, reducer 함수만 확인해보면 되므로 디버깅이 용이하죠.

부모 컴포넌트에 state를 두고 자식 컴포넌트들에서 해당 state를 업데이트 하는 상황을 가정해보겠습니다. 그리고 useState의 setter를 컴포넌트에서 직접 호출하는 것과 useReducer의 reducer 함수를 사용하는 것 중 어느 방법이 디버깅이 용이할지 그림으로 비교 해보겠습니다.

각각의 컴포넌트에서 useState의 setter를 사용하여 state를 업데이트 해주고 있습니다.

A Component 에서 잘못된 방법으로 state를 업데이트 해주었다고 가정해보겠습니다. (오류가 발생한 것은 아닙니다.) 우리는 state를 잘못 업데이트 하고 있는 위치를 이미 알고 있지만, 만약 저 위치를 모른다면 어떨까요?

A, B, C 각각의 컴포넌트를 직접 찾아가 모든 setter 로직을 검토하고 있었을 것입니다.

그리고 3개의 컴포넌트가 아닌 10개, 그 이상의 컴포넌트에서 상태를 업데이트 해주고 있었다면 디버깅이 상당히 어려워지겠죠.

반면에 useReducer를 사용한다면 자식 컴포넌트는 오로지 dispatch 함수를 통해 액션 타입을 보내기만 하고 모든 setter 로직이 reducer 함수에 몰려있으므로, 디버깅 시 컴포넌트를 일일히 찾아가는 수고를 덜 수 있을 것입니다. 😀

사실 커스텀 훅을 만들어 setter 로직을 분리한다면 useState로도 복잡한 state를 효율적으로 관리할 수도 있을 것 같습니다. 다음과 같은 구조가 되겠네요.

Action Type 대신 Next State

위에서 작성한 useReducer 사용 방법 내용 중에 reducer 및 dispatch 함수에 action type 대신 next state를 보내는 방법으로도 사용할 수 있다고 했습니다.

next state를 전달하여 사용한다면 useState와 비슷한 형식으로 사용이 가능합니다.

const reducer = (prevState, nextState) => {
  return { ...prevState, ...nextState };
};

export default function App() {
  const [state, setState] = useReducer(reducer, { name: 'Lee', age: 20 });

  return (
    <>
      <h3>{state.age}</h3>
      <button
        onClick={() => {
          setState({ age: state.age - 1 });
        }}
      >
        Decrease Age
      </button>
    </>
  );
}

위 코드에서 useState 보다 나은 점은 setState에서 이전 state를 전개해주지 않아도 된다는 것입니다. state의 형태가 간단하니 그냥 useState를 쓰고 전개해줘도 그렇게 큰 불편함이 없는 것은 사실입니다. useReducer를 쓸만한 장점은 무엇이 있을까요?

age 프로퍼티는 반드시 양수여야 한다고 가정해봅시다. 그렇다면 age가 0일 때 버튼을 누르더라도 더 이상 값이 감소되지 않아야겠죠. 비교를 위해 먼저 useState를 사용하여 구현해보겠습니다.

export default function App() {
  const [state, setState] = useState({ name: 'Lee', age: 20 });

  const decreaseAgeWhenPositiveNumber = () => {
    if (state.age <= 0) {
      setState({ ...state, age: state.age });
      return;
    }
    setState({ ...state, age: state.age - 1 });
  };

  return (
    <>
      <h3>{state.age}</h3>
      <button onClick={decreaseAgeWhenPositiveNumber}>Decrease Age</button>
    </>
  );
}

age가 0일 때 더 이상 감소하지 않습니다.

그렇다면 위에서 언급한 setState 로직이 분산되어 있을 때를 생각해봅시다. 모든 곳에는 일관된 state 갱신을 위해 setState 이전 반드시 검증 로직이 있어야함을 보장할 수 있어야합니다. 다음과 같은 형태가 되겠죠.

if(!isValidatedPositiveNumber(state.age)) return; // state.age 양수 검증 로직
setState({ age : state.age - 1 });

age가 양수임을 검증하는 로직이 중복되어 Validation 파일로 분리를 하고 재사용 한다고해도, setState 이전에 양수 검증 로직을 작성해야하는 사실은 분명합니다.

setState 로직이 분산되어 있는 상태에서 어느 순간 age가 음수 값이 나온다면, 어디서 잘못되었는지 찾는 디버깅 과정이 다소 성가실 것 같습니다.

그리고 setState({ age : state.age - 100 });과 같은 로직은 어떻게 예외 처리를 해줘야할까요? 현재 state.age가 20이라면 setState가 진행될테니까요. 그러면 age는 -80이 된 상태에서 양수 검증 로직에 의해 멈추게 됩니다.

위에서 제시한 문제를 해결하려면 state.age가 100보다 큼을 확인하는 로직이 추가로 필요할 것 같네요. 검증 로직을 관리할 포인트가 점점 늘어나는 거 같습니다.

그렇다면 useReducer는 어떨까요?

const reducer = (prevState, nextState) => {
  if (prevState.age === 0 || nextState.age < 0) {
    return { ...prevState, ...nextState, age: 0 };
  }
  return { ...prevState, ...nextState };
};

export default function App() {
  const [state, setState] = useReducer(reducer, { name: 'Lee', age: 20 });

  return (
    <>
      <h3>{state.age}</h3>
      <button
        onClick={() => {
          setState({ age: state.age - 1 });
        }}
      >
        Decrease Age
      </button>
    </>
  );
}

양수 검증 로직을 reducer 함수에 옮기면 되기 때문에, 더 이상 setState 이전에 검증 로직 작성을 강제할 필요가 없습니다. reducer 함수에서 age가 양수일 때만 값을 감소시킨다는 것을 보장해줄 수 있기 때문이죠.

그리고 setState({ age : state.age - 100 });과 같은 로직이 나와도 nextState를 검사해볼 수 있으므로 age가 음수가 아님을 보장할 수 있습니다. 개인적으로 useState 보단 검증 로직을 관리하는게 더 쉬워보입니다.

사실 위에서도 언급하였듯이 useReducer 대신, useState를 사용한 커스텀 훅으로도 충분히 구현 가능할 것 같습니다.

그래서 공식 문서에서도 개인 취향 차이라는 말도 있더군요. 공식문서에서 언급하는 useReducer의 장점은 다음과 같습니다.

  • 코드길이 : 반복된 set 로직을 재사용 할 수 있으므로
  • 가독성 : 복잡한 set 로직을 action으로 대체할 수 있으므로
  • 디버깅 : action을 제대로 보냈다면 reducer 로직만 확인하면 되므로
  • 테스팅 : reducer 함수는 리액트와 분리 되어있는 순수 자바스크립트 함수이므로

해당 문서를 참고하셔서 useState를 사용할지, useReducer를 사용할지 고민해보면 좋겠네요. 😀

switch default 대신 throw

useReducer를 액션 타입 방식으로 사용한다면 reducer 함수에 switch 문을 사용하여 액션 타입 별로 동작할 로직을 작성하는 것이 일반적인 방법입니다.

default 문을 두시는 것보다 throw 문을 두는 것이 개인적으로 더 낫다고 생각합니다. 액션 타입이 case에 맞지 않는 것을 보낼 이유가 없기 때문에 이는 사실상 오타 등에 의한 오류라고 봐야하지 않을까 싶습니다. 다음 코드를 보시죠.

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREASE':
      return state + 1;
    case 'DECREASE': {
      if (state <= 0) return state;
      return state - 1;
    }
    default:
      return state;
  }
};

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

  return (
    <>
      <h3>{state}</h3>
      <button
        onClick={() => {
          dispatch({ type: 'INCREASE' });
        }}
      >
        Increase Counter
      </button>
      <button
        onClick={() => {
          dispatch({ type: 'DECRESE' });
        }}
      >
        Decrease Counter
      </button>
    </>
  );
}

Decrease Counter 버튼을 눌러도 state가 감소하지 않았다면 양수 검증 로직에 의한 미업데이트인지, 액션 타입 에러로 인한 미업데이트인지 구분이 직관적이지가 않은 것 같네요.

따라서 액션 타입이 모든 case에 미 일치하는 경우에는 다음과 같이 명시적으로 에러를 throw 하는 것이 더 괜찮다고 생각합니다. 😀

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREASE':
      return state + 1;
    case 'DECREASE': {
      return state - 1;
    }
  }
  throw new Error('[ERROR] unknown action type');
};

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

  return (
    <>
      <h3>{state}</h3>
      <button
        onClick={() => {
          dispatch({ type: 'INCREASE' });
        }}
      >
        Increase Counter
      </button>
      <button
        onClick={() => {
          dispatch({ type: 'DECRESE' });
        }}
      >
        Decrease Counter
      </button>
    </>
  );
}

useReducer를 어떻게 쓰는지, 언제 쓰면 좋을지에 대해서 알아보았는데요. 간단하게 작성하려고 했는데 쓰다보니 길어지네요.. 잘못된 내용이 있다면 댓글로 지적해주시면 감사하겠습니다! 🙇

profile
woowacourse 5th FE

0개의 댓글