[번역] useState 지옥에서 벗어나기

eunbinn·2023년 1월 26일
234

FrontEnd 번역

목록 보기
16/31
post-thumbnail

출처: https://www.builder.io/blog/use-reducer

여러분은 리액트 useState 지옥에 빠져본 적이 있으신가요?

네 맞아요, 아래 예시와 같은 상황이죠.

import { useState } from "react";

function EditCalendarEvent() {
  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)} />
      {/* ... */}
    </>
  );
}

위 예시는 달력 이벤트를 업데이트하는 컴포넌트입니다. 아쉽지만 몇 가지 문제가 있죠.

한눈에 읽기 어렵다는 점도 있지만 세이프가드가 없다는 문제도 있습니다. 종료 날짜를 시작 날짜 이전으로 선택하는 모순을 막을 방법이 없습니다.

제목이나 설명이 너무 긴 경우에 대한 세이프가드도 존재하지 않죠.

물론 set*()를 호출하는 모든 곳에서 상태를 업데이트하기 전에 이 모든 조건을 검토할 것을 기억할 것이라고, 심지어는 알고 있을 것이라고 믿으며 노심초사하고 있을 수도 있습니다. 하지만 저는 깨지기 쉬운 상태에 놓여있다는 것을 알고도 마냥 안심하지는 못할 것 같습니다.

useState를 대체할 더 강력한 것이 있습니다

useState를 대체할 수 있는 생각보다 사용하기 쉽고 더 강력한 상태 훅이 있다는 것을 알고 계셨나요?

useReducer를 사용하면 위 예시 코드를 아래와 같이 변경할 수 있습니다.

import { useReducer } from "react";

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

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

useReducer 훅을 사용하면 상태 A에서 상태 B로의 변환을 제어할 수 있습니다.

누군가는 "useState를 사용해서도 가능해요"라고 말씀하시면서 아래와 같은 코드를 제시할 수 있습니다.

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 })}
      />
      {/* ... */}
    </>
  );
}

틀린 말은 아닙니다만, 여기서 놓친 중요한 포인트가 있습니다. 이러한 포맷은 항상 ...event로 전개하여 객체를 직접 변경하지 않도록 해야 한다는 것입니다. 뿐만 아니라 useReducer의 중요한 이점인 상태 변환을 제어할 수 있는 함수를 추가할 수 있다는 점 또한 여전히 놓치고 있습니다.

다시 useReducer로 돌아가서, 유일한 차이점은 각 상태의 변화가 안전하고 유효할 것을 보장하는 함수를 추가로 인자에 전달한다는 점입니다.

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

이는 상태를 한 곳에서 관리하며 언제나 유효하다는 것을 보장한다는 이점을 갖습니다.

따라서 이러한 모델을 사용하면 이후에 다른 코드들이 추가되더라도, 팀의 새로운 멤버가 updateEvent()를 유효하지 않은 데이터와 함께 호출하더라도 상태 값을 검증하는 콜백이 실행될 것입니다.

예를 들어 언제 어디서 상태가 업데이트되든 절대 종료 날짜가 시작 날짜 이전일 수 없고 (이는 말이 안 되기 때문에), 제목의 길이가 최대 100자를 넘지 않도록 보장하고자 한다고 가정해봅시다.

import { useReducer } from "react";

function EditCalendarEvent() {
  const [event, updateEvent] = useReducer(
    (prev, next) => {
      const newEvent = { ...prev, ...next };
      // 시작 날짜가 종료 날짜 이후가 될 수 없음을 보장합니다
      if (newEvent.startDate > newEvent.endDate) {
        newEvent.endDate = newEvent.startDate;
      }
      // 제목이 100자를 넘을 수 없음을 보장합니다
      if (newEvent.title.length > 100) {
        newEvent.title = newEvent.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>;
}

이런 간단한 예시에서도, count는 무한대로 커질 수 있을까요? 음수가 되어야 하는 경우는 없을까요?

네 물론 이 예시에서 음수 값이 나오는 경우는 없겠지만 카운트에 제한을 설정하고자 하는 경우 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스러운 것들

상황이 더욱 복잡해지면 redux 스타일의 action 기반 패턴을 사용할 수도 있습니다.

위에서 언급했던 달력 예시로 돌아가 봅시다. 아래와 같이 코드를 다시 작성해 볼 수 있습니다.

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" })}
      />
      {/* ... */}
    </>
  );
}

useReducer에 대한 문서나 글들을 보면 모두 이 방법이 useReducer 훅을 사용하는 유일한 방법처럼 설명합니다.

하지만 이 방법은 useReducer 훅을 사용할 수 있는 다양한 방법 중 하나의 방법일 뿐이라는 점을 강조하고 싶습니다. 주관적인 의견이지만, 개인적으로 Redux와 이러한 패턴을 좋아하진 않습니다.

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

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

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.type) {
    case "incremented_age": {
      // ✅ Correct: 새로운 객체를 생성합니다
      return {
        ...state,
        age: state.age + 1,
      };
    }
    case "changed_name": {
      // ✅ Correct: 새로운 객체를 생성합니다
      return {
        ...state,
        name: action.nextName,
      };
    }
    // ...
  }
}

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

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

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

use-immer 패키지는 추가로 useImmerReducer 함수를 제공하는데 이 함수를 사용하면 직접적인 변경을 통한 상태 변경이 가능합니다. 라이브러리 내부적으로 자바스크립트 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와 같은 훨씬 더 확장하기 쉬운 솔루션을 검토해보는 것이 좋습니다.

언제나 잊지 마세요. 단순하게 시작하고 필요한 경우에만 복잡성을 추가하세요.

7개의 댓글

comment-user-thumbnail
2023년 1월 27일

너무 좋은 글이네요! state가 많아지면 관리가 어려워졌었는데 많은 도움이 됐습니다. :D

답글 달기
comment-user-thumbnail
2023년 1월 27일

좋은 글 잘 읽었어요. Jotai도 살짝 추천하고 갑니다 :)

답글 달기
comment-user-thumbnail
2023년 1월 27일

막연히 잊고 있었던 useReducer의 존재감을 일깨워주는 포스팅이네요. 잘 보았습니다

답글 달기
comment-user-thumbnail
2023년 2월 2일

좋은 글 잘 읽었습니다.

답글 달기
comment-user-thumbnail
2023년 2월 4일

?

답글 달기
comment-user-thumbnail
2023년 3월 8일

좋은글 잘 읽었습니다

답글 달기
comment-user-thumbnail
2023년 7월 28일

좋은 글 잘 읽었습니다!

답글 달기