지나가던 개발(zigae)

useState 과도하게 사용하지 않기

2021년 10월 21일 • ☕️ 3 min read

react-logo


useStateReact에서 제공하는 가장 기본적인 훅이며, useEffect와 함께 많이 사용되는 훅이기도 하다.

필자는 최근 주변에서 이러한 hook이 잘못 사용되는 경우를 종종 봤다. hook 사용법 자체는 어렵지 않아 문제없지만 상태 관리는 결코 쉽지 않기 때문이다.

이제부터 hook을 우아하게 사용할 수 있는 방법을 간략하게 설명하고자 한다.

state란 무엇일까?

필자는 이번 챕터의 모든 것은 “state가 무엇인지 이해”하는 것으로 귀결된다 생각한다. state에 대해 3가지 질문을 해보자

  • props 통해 부모로부터 전달되나요? 그렇다면 state가 아닙니다.
  • 시간이 지나도 변하지 않나요? 그렇다면 state가 아닙니다.
  • 컴포넌트의 다른 state 또는 props을 기반으로 계산할 수 있나요? 그렇다면 state가 아닙니다.

첫 번째와 두 번째(요청으로 다음 포스트에서 다룰 예정) 질문은 쉽다. 하지만 세 번째 질문인 state 또는 props로 부터 계산할 수 있는 값이 state가 아니라는것은 명백하지만 고민이 필요하다. 그렇기에 이 문제는 개발자들에게서 흔히 볼 수 있는 패턴이다.

Example

예시 코드는 간단하며 다음과 같이 진행된다. 서버에서 데이터를 페칭하고 사용자가 카테고리별로 필터링하도록 한다.

상태 관리 방식은 대부분 다음과 같다.

import { fetchData } from './api'
import { computeCategories } from './utils'

const App = () => {
  const [data, setData] = React.useState(null)
  const [categories, setCategories] = React.useState([])

  React.useEffect(() => {
    async function fetch() {
      const response = await fetchData()
      setData(response.data)
    }

    fetch()
  }, [])

  React.useEffect(() => {
    if (data) {
      setCategories(computeCategories(data))
    }
  }, [data])

  return <>...</>
}

언뜻 보기에 괜찮아 보인다면 다음과 같이 생각했을 것이다. 데이터를 페칭 effect와 카테고리를 데이터와 싱크를 맞춰 state로 유지하는 또 다른 effect가 있다. 이것이 현재 useEffect 훅의 목적이다. 그렇다면 이 접근 방식의 단점은 무엇일까?

동기화 해제

이것은 실제로 잘 작동하며 가독성이 떨어지거나 추론하기에 어려움은 없다. 문제는 향후 다른 개발자가 “publicly”하게 사용 가능한 setCategories이 있다는 것이다.

useEffect로 의도한 것이 카테고리를 서버 데이터에 의존하도록 설계 한 것이었다면 이는 좋지 않은 소식이다.

import { fetchData } from './api'
import { computeCategories, getMoreCategories } from './utils'
const App = () => {
  const [data, setData] = React.useState(null)
  const [categories, setCategories] = React.useState([])

  React.useEffect(() => {
    async function fetch() {
      const response = await fetchData()
      setData(response.data)
    }

    fetch()
  }, [])

  React.useEffect(() => {
    if (data) {
      setCategories(computeCategories(data))
    }
  }, [data])

  return (
    <>
      ...
      <Button onClick={() => setCategories(getMoreCategories())}>        Get more
      </Button>
    </>
  )
}

과연 이제는 어떨까? 우리는 “카테고리”가 무엇인지 예측할 수 있는 방법이 없다.

  • 처음 페이지가 로드 됐을 때 A 카테코리
  • 사용자가 버튼을 클릭 했을 때 B 카테고리
  • 네트워크에 다시 연결할 때 refetch 할 수 있는 기능이 있는(useSWR, react-query) 라이브러리를 사용하고 있어 다시 실행된다면 카테고리는 다시 A

무심코 작성한 우리의 코드는 간헐적으로 발생하는 상황으로 인해 추적하기 어려운 버그가 생겼다.

필요없는 state

이것은 결국 useState에 관한 것이 아니라 useEffect에 대한 잘못된 사용법이다. 이러한 방법은 React 외부의 값을 동기화하는데 사용되어야 한다. useEffect를 사용하여 두 state를 동기화하는 것은 대부분 옳지 않다.

그래서 필자는 다음과 같이 가정하고자 한다.

state setter 함수가 useEffect에서 동기적으로 사용되면 state를 제거하자

짧지만 강력한 조언이다. 더 나가아서 계산 비용이 비싸다는 것을 프로파일링 해서 증명하지 않는 한 메모이제이션하는 것에 대부분 문제 삼지 않을 것이다.(성급하게 최적화하지 말고 프로파일링을 통한 근거를 만들자)

import { fetchData } from './api'
import { computeCategories } from './utils'

const App = () => {
  const [data, setData] = React.useState(null)
-  const [categories, setCategories] = React.useState([])
+  const categories = data ? computeCategories(data) : []

  React.useEffect(() => {
    async function fetch() {
      const response = await fetchData()
        setData(response.data)
      }

      fetch()
    }, [])

-  React.useEffect(() => {
-    if (data) {
-      setCategories(computeCategories(data))
-    }
-  }, [data])

  return <>...</>
}

effect의 양을 반으로 줄임으로서 복잡성을 줄이고, 이제 카테고리는 데이터에서 파생된 것을 명확하게 확인할 수 있다. 다른 개발자가 카테고리를 다르게 계산하려면 함수 내 computeCategories를 실행해야 한다. 우리는 항상 카테고리가 무엇이며 어디에서 왔는지에 대한 명확한 이해를 할 수 있을 것이다.

이러한 리팩토링 과정을 우리는 이렇게 부른다. 단일 진실 공급원(A single source of truth)