지나가던 개발(zigae)

상태관리의 선택, useState, useReducer

2022년 1월 30일 • ☕️ 5 min read

react-logo

어떤 방식으로 상태관리를 사용할 것인가에 대한 고민은 React의 라이브 기간(또는 더 오래전에)과 함께 했으며, 이에 대한 답변은 다양하다. 필자는 그 중 하나의 답을 제시할 것이고, 겉보기 복잡해 보일 수 있는 질문에 모두 같은 답변을 할 것이다.

상횡에 따라 다르다.

글을 쓰기 싫은 것이라 생각할 수 있다. 하지만 진심이다. 상태의 유형, 업데이트 빈도, 또는 상태의 범위에 따라 다르다.

필자는 주로 서버 상태는 react-query에 대한 선호도가 높다. 오늘은 서버 상태를 배제하고 살펴보겠다.

Client State

Hooks 이전에 클라이언트 상태를 로컬에서 관리하는 방법은 클래스 기반 컴포넌트의 this.setState 뿐이었다. 상태는 객체여야 하고 업데이트 함수는 객체를 이뮤터블하게 받아 관리했다.

Hooks의 등장은 이를 근본적으로 변화시켰다. 이제 함수형 컴포넌트에서 상태를 관리할 수 있을 뿐만 아니라 useState, useReducer를 사용하여 두 가지 방법을 사용할 수 있습니다.

대부분의 사람들이 클래스 기반 상태 관리에서 hooks로 전환하기 위해 접근한 방법은 객체의 필드를 분할하고 각 필드에 대해 useState로 바꾸는 것이라 생각한다.

Before:

class Names extends React.Component {
  state = {
    name: '',
    email: '',
  }

  render() {
    return (
      <div>
        <input
          value={this.state.name}
          onChange={(event) =>
            this.setState({ name: event.target.value })
          }
        />
        <input
          value={this.state.email}
          onChange={(event) =>
            this.setState({ email: event.target.value })
          }
        />
      </div>
    )
  }
}

After:

const Contact = () => {
  const [name, setName] = React.useState('')
  const [email, setEmail] = React.useState('')

  return (
    <div>
      <input
        value={name}
        onChange={(event) => setName(event.target.value)}
      />
      <input
        value={email}
        onChange={(event) => setEmail(event.target.value)}
      />
    </div>
  )
}

이러한 접근은 함수형 컴포넌트로 전환하는 정통적인 방법이며, 이제 두 필드는 독립적으로 업데이트되므로 자급자족(self-sufficient)이 가능하다는 점에서 의미가 있다.

하지만 항상 독립적인 것은 아니다. 경우에 따라 함께 업데이트 되는 상태가 있을 수 있다. 필자는 이러한 상황에서는 여러 useState로 나누는 것은 꽤 불합리하게 느껴진다.

마우스 좌표(x/y)를 저장하는 예를 들어보자. 두 개의 useState를 사용하여 항상 함께 업데이트되는 것은 이상할 수 있기 때문에 여기서 단일 상태 개체를 사용한다.

const App = () => {
  const [{ x, y }, setCoordinates] = React.useState({ x: 0, y: 0 })

  return (
    <button
      onClick={(event) => {
        setCoordinates({ x: event.screenX, y: event.screenY })
      }}
    >
      Click, {x} {y}
    </button>
  )
}

Multiple States

단일 useState 객체는 사용할 때마다 구조가 다를 수 있고 한 번에 하나의 필드만 업데이트 하고자 하는 단순한 방식에 적합하다고 생각한다. useStates로 상태를 여러 개 가질 수는 없기 때문에 다음과 같은 custom hook을 만들수도 있다.

const useStates = <State extends Record<string, unknown>>(
  initialState: State
) => {
  const [values, setValues] = React.useState(initialState)
  const update = <Key extends keyof State>(name: Key, value: State[Key]) =>
    setValues((form) => ({ ...form, [name]: value }))

  return [values, update] as const
}

useState의 상태를 분할할지 여부를 결정하기 위해 다음 규칙을 따른다.

함께 업데이트 되는 상태는 함께 있어야 한다.

일괄 처리

한 행에 여러 useState를 호출하는 것보다 단일 상태를 사용하는 것을 고려하자. React는 동기식 이벤트 핸들러에서 이러한 상태 업데이트를 일괄 처리하는데 뛰어나지만 비동기식 함수에서의 일괄처리에는 어려움이있다. 이 문제는 Automatic Batching in React 18으로 문제가 개선 되겠지만, 어떤 상태가 함께 업데이트 되는지 추론할 수 있는 방식으로 코드를 구조화하면 성능 문제에 관계없이 장기적으로 가독성과 유지보수 비용을 절감할 수 있다.

useReducer

필자는 아직 useReducer가 많이 사용되지 않는다 생각한다. useReducer에 대한 주요 포인트가 “복잡한 상태”에만 필요한 것으로 보일 수 있기 때문이다. 하지만 앞서 설명한 것처럼 상태를 전환하는 것에도 적합하다.

const [value, toggleValue] = React.useReducer(previous => !previous, true)

<button onClick={toggleValue}>Toggle</button>

예시는 특별히 복잡한 것은 없으며 useReducer의 유연성을 잘 보여준다 생각한다. 즉, 서로 다른 “작업(동작)“에서 상태의 여러 부분을 업데이트할 때도 효과가 나타난다. 첫 번째 단계에서 선택한 데이터에 따라 두 번째 단계 단계를 초기화하거나 두 번째 단계로 돌아갈 때 세 번째 단계의 데이터를 비운다 가정해보자. 복잡한 상태를 가지기 위해 일부러 복잡한 상황을 묘사하였다.

각 단계마다 하나씩 독릭접인 useStates가 있을 때 setState를 여러 번 연속으로 호출해야 하며, 단일 상태인 경우에도 상당히 복잡해질 수 있다.

useReducer tips

useReducer를 사용할 때 redux style guide를 따르자. 훌륭한 글이기도 하지만 대부분의 요점을 useReducer로 대치할 수 있다.

Event driven reducers

reducer는 이뮤터블하게 동작하고 사이드 이펙트가 없도록 하는 것은 대부분의 사람들이 지킬 것이다. 이유는 React에서 요구하는 것과 일치하기 때문이다.

액션을 이벤트로 모델링하는 것은 리듀서의 가장 큰 장점 중 하나이고, 강조하고 싶은 부분이다. 이렇게 하면 UI의 다양한 부분에 분산되는 대신 어플리케이션의 비지니스 로직을 reducer 내부에서 관리 가능하다. 이렇게 하면 상태관리를 더 쉽게 추론할 수 있을 뿐만 아니라 로직을 테스트하기 쉬워진다.(실제로 순수 함수는 테스트하기 편하다.)

개념을 설명하기 위해 일반적인 카운터 예제를 살펴보자.

const reducer = (state, action) => {
  // ✅ UI에서는 이벤트만 dispatch 하고, 로직은 리듀서에 있다.
  switch (action) {
    case 'increment':
      return state + 1
    case 'decrement':
      return state - 1
  }
}

function App() {
  const [count, dispatch] = React.useReducer(reducer, 0)

  return (
    <div>
      Count: {count}
      <button onClick={() => dispatch('increment')}>Increment</button>
      <button onClick={() => dispatch('decrement')}>Decrement</button>
    </div>
  )
}

로직은 그다지 유용하지 않지만 로직 이라는 것에 집중하자. Incremnet, Decrement 를 클릭할 때마다 숫자의 크기를 증가 또는 감소 시키거나 상한선, 하한선을 두도록 확장하기 편하다. 이 모든 것이 reducer 내부에서 발생한다. 이를 dumb한 상태의 reducer와 비교해보자.

const reducer = (state, action) => {
  switch (action.type) {
    // 🚨 reducer는 아무것도 하지 않는다.
    case 'set':
      return action.value
  }
}

function App() {
  const [count, dispatch] = React.useReducer(reducer, 0)

  return (
    <div>
      Count: {count}
      <button onClick={() => dispatch({ type: 'set', value: count + 1 })}>
        Increment
      </button>
      <button onClick={() => dispatch({ type: 'set', value: count - 1 })}>
        Decrement
      </button>
    </div>
  )
}

동일하게 동작하지만 이전 예제만큼 확장성이 뛰어나지 않다. 일반화 시켜 말하자면 action의 이름에 set이 포함된 행동을 피해보자.

오늘은 여기까지다. 도입부의 어떤 방식으로 상태관리를 사용할 것인가에 대한 고민의 결정을 내리는데 도움이 됐길 바란다.

참조