본문 바로가기
JavaScript./React

useReducer로 useState 지옥 벗어나기

by dev챙 2024. 7. 26.

useReducer로 useState 지옥 벗어나기

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

useState 지옥에서 벗어나기

기존 코드

아래는 달력 이벤트를 업데이트하는 컴포넌트 예시입니다.

import { useState } from "react";

function EditCalendatEvent() {
  const [startDate, setStartDate] = useState();
  const [endDate, setEndDate] = useState();
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [location, setLocation] = useState();
  const [attendees, setAttendees] = useState([]);

  return (
    <>
      <input value={title} onChange={e => setTitle(e.target.value)} />
      {/* ... */}
    </>
  );
}

useState 지옥에 빠졌을 때 문제점

  • 한눈에 파악하기 어렵다.
  • 세이프가드가 없다.
    • 종료 날짜를 시작 날짜 이전으로 선택하는 모순을 막을 방법이 없다.
    • 제목이나 설명이 너무 긴 경우에 대한 세이프가드도 없다.

useState를 대체할 상태관리 = useReducer를 써보자

useReducer를 사용한 코드

import {useReducer} from "react";

function EditCalendarEvenr () {
 const [event, undateEvent] = useReducer(
    (prev, next) => {
        return { ...prev, ...next };
    },
    { title: "", description: "", attendees: [] }
 );

 return (
     <>
         <input
             value={event.title}
             onChange={(e)=> updateEvent({ title: e.target.value })}
         />
         { /*...*/ }
    </>)
 );
}

위의 코드를 useState로 비슷하게 한다면 아래와 같은 코드를 사용할 수 있지만 아래와 같은 포맷은 항상 ...event 로 전개연산자를 사용하여 직접 변경하지 않도록 해야한다는 단점이 있다.
또한 useReducer의 중요한 이점인 상태 변환을 제어할 수 있는 함수를 추가할 수 있다는 점 또한 놓치게 된다.

import { useState } from "react";

function EditCalendarEvent() {
  const [event, setEvent] = useState({
    title: "",
    description: "",
    attendees: [],
  });

  return (
    <>
      <input value={event.title} onChange={e => setEvent({ ...event, title: e.target.value })} />
      {/* ... */}
    </>
  );
}

useReducer의 유일한 차이점은 각 상태의 변화가 안전하고 유효할 것을 보장하는 함수를 추가로 인자에 전달하여 상태를 한 곳에서 관리하며 항상 보장한다는 장점을 가진다.

const [event, updateEvent] = useReducer(
  (prev, next) => {
    // 이벤트를 검증하고 변환하여 상태가 항상 유효할 것을 한 곳에서 관리하며 보장합니다
    // ...
  },
  { title: "", description: "", attendees: [] }
);

후에 다른 코드들이 추가 되더라도, 팀의 다른 팀원이 updateEvent()를 유효하지 않은 데이터와 함께 호출하더라도 상태 값을 검증하는 콜백이 실행될 것이다.

useState코드의 문제였던 세이프가드를 useReducer로 작성해보자.

import { useReducer } from "react";

function EditCalendatEvent() {
  const [event, updateEvent] = useReducer(
    (prev, next) => {
      const newEvent = { ...prev, ...next };
      // 시작 날짜가 종료 날짜 이후가 될 수 없음을 보장합니다.
      if (newEvent.startDate > newEvent.endDate) {
        newEvent.title = newEvnet.title.substring(0, 100);
      }
      return newEvent;
    },
    { title: "", description: "", attendees: [] }
  );
  return (
    <>
      <input value={event.title} onChange={e => updateEvent({ title: e.target.value })} />
      {/*...*/}
    </>
  );
}

UI로도 입력값이 유효한지 여부를 표시해줘야한다. 위와 같은 코드가 길어질 수록 중요한 세이프가드를 제공할 수 있다. 데이터베이스에 ORM처럼 안전성을 보장하는 하나의 세트로 생각하면 상태 값이 항상 유효하다는 것을 확신할 수 있다. 위와 같은 코드로 향후 원인을 알 수 없거나 디버깅하기 힘든 문제가 발생하지 않도록 방지할 수 있다.

대부분의 useState를 useReducer로 대체할 수 있다

아래는 useState를 사용한 가장 간단한 카운터 컴포넌트이다.

import { useState } from "react";

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

  return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}

위 코드를 useReducer로 간단한 예외 처리를 추가해 변경해보자.

import { useReducer } from "react";

function Counter() {
  const [count, setCount] = useReducer((prev, next) => Math.min(next, 10), 0);

  return <button onClick={() => setCount(count + 1)}>Count is {count}</button>;
}

(선택 사항) Redux스러운 것들

useReducer에 대한 문서나 글들을 보면 대부분이 이 방법으로 useReducer 훅을 사용하는 방법으로 소개하고 있다. 하지만 이 방법은 useReducer 훅을 사용할 수 있는 다양한 방법 중 하나일 뿐이다.

import { useReducer } from "react";

function EditCalendarEvent() {
  const [event, updateEvent] = useReducer(
    (state, action) => {
      const newEvent = { ...state };

      switch (action.type) {
        case "updateTitle":
          newEvent.title = action.title;
          break;
        // action 등등
      }
      return newEvent;
    },
    { title: "", description: "", attendees: [] }
  );

  return (
    <>
      <input value={event.title} onChange={e => updateEvent({ type: "updateTitle", title: "Hello" })} />
      {/*...*/}
    </>
  );
}

Redux와 위와 같은 패턴의 장점이 있지만 action에 대한 새로운 추상화를 레이어링하기 시작한다면 Mobx, Zustand, XState와 같은 라이브러리를 추천하고 싶다.

그래도 추가 종속성 없이 이 패턴을 활용할 때 더 우아하기 때문에 Redux와 같은 형식을 좋아하는 사람들을 위해 제안한다.

Reducer 공유하기

useReducer의 또 다른 좋은 점은 이 훅에 의해 컨트롤되는 데이터를 자식 컴포넌트에서 업데이트하고자 할 때 편리하다는 것이다. useState 에서는 여러 개의 함수들을 전달해야 했지만 useReducer에서는 reducer 함수만을 전달하면 된다.

리액트 문서에서 설명하고 있는 예시는 다음과 같다.

const TodosDispatch = React.createContext(null);

function TodosApp() {
  // 참고: `dispatch` 는 리렌더 간에 변하지 않습니다
  const [todos, updateTodos] = useReducer(todosReducer);

  return (
    <TodosDispatch.Provider value={updateTodos}>
      <DeepTree todos={todos} />
    </TodosDispatch.Provider>
  );
}

자식 컴포넌트에서는 아래와 같이 사용하면 된다.

function DeepChild(props) {
  // action을 수행하고 싶다면 context로부터 dispatch를 전달받으면 됩니다.
  const updateTodos = useContext(TodosDispatch);

  function handleClick() {
    updateTodos({ type: "add", text: "hello" });
  }

  return <button onClick={handleClick}>Add todo</button>;
}

이렇게 하면 통일된 하나의 업데이트 함수를 가질 수 있을 뿐만 아니라 자식 컴포넌트로부터 상태가 업데이트되어도 요구 사항에 부합하도록 안전성을 보장할 수 있다.

흔히 빠질 수 있는 함정

useReducer 훅의 상태 값은 항상 불변해야 함에 주의해야 한다. 만약 reducer 함수에서 실수로 객체를 직접 변경시켰다면 몇 가지 문제가 발생할 수 있다.

리액트 문서에서 설명하고 있는 예시를 살펴보자.

function reducer(state, action) {
  switch (action.type) {
    case "incremented_age": {
      // 🚩 Wrong: 기존 객체를 변경시켰습니다.
      state.age++;
      return state;
    }
    case "changed_name": {
      // 🚩 Wrong: 기존 객체를 변경시켰습니다.
      state.name = action.nextName;
      return state;
    }
    // ...
  }
}

이를 올바르게 고치면 다음과 같다.

function reducer(state, action) {
  switch ((action, state)) {
    case "incremented_age": {
      // ✅ Correct : 새로운 객체를 생성합니다.
      return {
        ...state,
        age: state.age + 1,
      };
    }
    case "changed_name": {
      // ✅ Correct: 새로운 객체를 생성합니다.
      return {
        ...state,
        name: action.nextName,
      };
    }
    // ...
  }
}

만약에 이런 문제를 자주 만난다면 라이브러리로부터 도움을 받을 수도 있습니다.

(선택 사항) 흔히 빠질 수 있는 함정 해결법: Immer

Immer는 우아하고 가변적인 DX(digital transformation)를 가지고 있으면서도 데이터의 불변을 보장하는 매우 훌륭한 라이브러리이다.

use-immer패키지는 추가로 useImmerReducer함수를 제공하는데 이 함수를 사용하면 직접적인 변경을 통한 상태 변경이 가능하다. 라이브러리 내부적으로 자바스크립트 [[new Proxy]]를 사용해서 불변한 복사본을 만들어 주는 것이다.

import { useImmerReducer } from "use-immer";

function reducer(draft, action) {
  switch (action.type) {
    case "increment":
      draft.count++;
      break;
    case "decrement":
      draft.count--;
      break;
  }
}

function Counter() {
  const [state, dispatch] = useImmerReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
}

그래서 언제 useState를 사용하고 언제 useReducer를 사용해야할까?

useReducer에 대해 사용할 수 있는 다양한 경우를 설명했지만, 성급하게 추상화하지 않겠다.
보통의 경우 useState를 사용해도 괜찮지만 상태와 검증 조건들이 복잡해지기 시작하며 추가적인 노력이 들어가기 시작한다고 느껴지면 그때 점진적으로 useReducer를 고려해도 좋다.

후에 복잡한 객체들에 useReducer를 사용하기 시작하고 상태 변경에 따른 위험에 자주 직면할 때 Immer의 사용을 고려해 볼 수 있다.

혹은 상태 관리가 복잡해진 시점에 도달했다면 Mobx, Zustand, XState와 같은 훨씬 더 확장하기 쉬운 솔루션을 검토해보는 것이 좋다.

항상 잊지말아야할 것은 단순하게 시작하고 필요한 경우에만 복잡성을 추가하는 것이다.

☑️ 참고 페이지

(번역)useState지옥 벗어나기
A Cure for React useState Hell?