본문 바로가기

카테고리 없음

[번역] React Query and React Context

 

React Query and React Context

Can it make sense to combine React Query with React Context ? Yes, sometimes ...

tkdodo.eu

 

React Query의 가장 큰 특징 중 하나는 컴포넌트 트리에서 원하는 곳 어디에서나 쿼리를 사용할 수 있다는 것입니다. 아래의 ProductTable 컴포넌트는 필요한 데이터를 fetch하고 이를 필요한 곳에 co-locate 시킵니다. 

 

function ProductTable() {
  const productQuery = useProductQuery()

  if (productQuery.data) {
    return <table>...</table>
  }

  if (productQuery.isError) {
    return <ErrorMessage error={productQuery.error} />
  }

  return <SkeletonLoader />
}

 

제게는 이 기능이 아주 좋은데, ProductTable을 분리하고 독립적으로 만들 수 있기 때문입니다: 자체 의존성을 읽을 책임이 있습니다(제품 데이터) 이미 캐시에 있으면 좋으니 그냥 읽으면 됩니다. 없으면 가져올 것입니다. React Server 컴포넌트에서도 비슷한 패턴이 나타나는 것을 볼 수 있습니다. 더이상 상태 저장 컴포넌트와 상태 비저장 컴포넌트, 또는 스마트 컴포넌트와 dumb 컴포넌트를 구분할 필요가 없습니다.

 

따라서 컴포넌트에서 필요한 곳에서 바로 데이터를 가져올 수 있다는 것은 매우 유용합니다. 말 그대로 ProductTable 컴포넌트를 앱의 어느 곳으로든 옮기면 바로 작동합니다. 이 컴포넌트는 변경에 매우 탄력적이기 때문에, 상태 관리자로서의 React Query와 #21에서 커스텀 훅을 통해 필요한 곳에서 직접 쿼리에 엑세스하는 것을 옹호하는 주된 이유입니다. 

 

하지만 이는 만병통치약이 아니며 단점이 있습니다. 결국 모든 것은 장단점이 있기 때문에 이는 놀라운 일은 아닙니다. 하지만 우리가 여기서 정확히 어떤 거래를 하고 있는 걸까요?

 

Being Self-Contained

컴포넌트가 자율적이라는 것은 쿼리 데이터를 (아직) 사용할 수 없는 경우, 특히 로딩 및 오류 상태를 처리해야 한다는 것을 의미합니다.

<ProductTable> 컴포넌트는 처음 로드될 때 실제로 <SkeletonLoader/>를 표시하는 경우가 많기 때문에 큰 문제가 되지 않았습니다. 

 

하지만 쿼리의 일부에서 일부 정보만 읽고 싶을 때, 쿼리가 트리의 더 위쪽에서 이미 사용되었다는 것을 알고 있는 다른 상황도 많이 있을 수 있습니다. 예를 들어 로그인한 사용자에 대한 정보가 포함된 useQuery가 있을 수 있습니다. 

 

export const useUserQuery = (id: number) => {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUserById(id),
  })
}
export const useCurrentUserQuery = () => {
  const id = useCurrentUserId()

  return useUserQuery(id)
}

 

 

이 쿼리는 컴포넌트 트리의 아주 초기 단계에서 로그인한 사용자가 어떤 사용자 권한을 가지고 있는지 확인하기 위해 사용할 수 있으며, 실제로 페이지를 볼 수 있는지 여부를 결정할 수도 있습니다. 이는 페이지의 모든 곳에서 필요한 필수 정보입니다. 

 

이제 트리의 더 아래쪽에 사용자 이름을 표시하려는 컴포넌트가 있을 수 있으며, 이 컴포넌트는 useCurrentUserQuery 훅에서 가져올 수 있습니다. 

 

function UserNameDisplay() {
  const { data } = useCurrentUserQuery()
  return <div>User: {data.userName}</div>
}

 

물론 타입스크립트는 데이터가 정의되지 않았을 가능성이 있기 때문에 허용하지 않습니다. 하지만 이 상황에서는 쿼리가 트리의 더 위쪽에서 이미 시작되지 않으면 UserNameDisplay가 렌더링되지 않으므로 정의되지 않을 수 없다는 것을 잘 알고 있습니다. 

 

약간 딜레마가 있습니다. 정의될 것을 알기 때문에 여기서 TS를 조용히시키고 data!.userName을 사용해야 할까요? 아니면 안전을 위해 data?.userName을 사용할까요(여기서는 가능하지만 다른 상황에서는 쉽지 않을 수 있습니다.) 아니면 if(!data)가 null을 반환하는 가드만 추가할까요? 아니면 useCurrentUserQuery를 호출하는 25곳에 모두 적절한 로딩과 오류 처리를 추가할까요?

 

솔직히 말해서 저는 이 모든 방법이 차선책이라고 생각합니다. 제가 아는 한 "절대 일어나지 않을" 검사로 코드베이스를 가득 채우고 싶지 않습니다. 하지만 (늘 그렇듯이) TS가 옳기 때문에 TypeScript를 무시하고 싶지도 않습니다. 

 

An implicit dependency

문제는 암시적 의존성이 있다는 사실에서 비롯됩니다: 암시적 의존성은 애플리케이션 구조에 대한 지식과 머릿속에만 존재할 뿐 코드 자체에는 보이지 않는 속성입니다. 

 

정의되지 않은 데이터를 확인하지 않고도 안전하게 useCurrentUserQuery를 호출할 수 있다는 것을 알고 있지만, 정적 분석으로는 이를 확인할 수 없습니다. 동료들은 이 사실을 모를 수도 있습니다. 저 역시 3개월 후에도 이 사실을 모를 수 있습니다

 

가장 위험한 부분은 지금은 맞을지 모르지만 미래에는 더 이상 맞지 않을 수 있다는 것입니다. 캐시에 사용자 데이터가 없을 수도 있고, 이전에 다른 페이지를 방문한 경우와 같이 조건부로 캐시에 사용자 데이터가 있을 수도 있는 앱의 어딘가에 UserNameDisplay의 다른 인스턴스를 렌더링하기로 결정할 수 있습니다. 

 

이것은 <ProductTable> 컴포넌트와는 정반대입니다: 변경에 탄력적인 대신 리팩터링에 오류가 발생하기 쉽습니다. 서로 관련이 없어 보이는 컴포넌트 몇 개를 옮겼다고 해서 UserNameDisplay 컴포넌트가 깨지지는 않을 것입니다. 

 

Make it explicit

물론 해결책은 의존성을 명시적으로 만드는 것입니다. 그리고 React Context보다 이를 해결하기 위한 더 좋은 방법은 없습니다. 

 

React 컨텍스트에 대한 오해가 많으니 바로 잡아보겠습니다: React Context는 state 관리자가 아닙니다. useState와 useReducer와 함께 사용하면 상태 관리를 위한 겉보기에는 좋은 솔루션이 될 수 있지만 저는 이런 상황에서 너무 많은 화상을 입었기 때문에 이런 방식을 좋아하지 않습니다. 

 

따라서 전용 도구를 사용하는 것이 더 나을 수 있습니다. Mark Erickson이 이 주제에 대한 매우 긴 블로그 포스트를 작성했습니다. Why React Context is Not a State Management Tool. 

 

제 트윗에 이미 언급되어있습니다. React Context는 의존성 주입 도구입니다. 이를 통해 컴포넌트가 작동하는데 필요한 "things"를 정의할 수 있으며 모든 부모 컴포넌트는 해당 정보를 제공할 책임이 있습니다. 

 

이는 개념적으로 여러 레이어를 통해 소품을 전달하는 과정인 prop drilling 과 동일합니다. 컨텍스트를 사용하면 몇 가지 값을 가져와 자식에게 소품으로 전달할 수 있지만, 몇 개의 레이어를 생략할 수 있다는 점만 다릅니다:

 

 

컨텍스트를 사용하면 중간자를 건너뛸 수 있습니다. useCurrentUserQuery 예제에서는 이러한 의존성을 명시적으로 만드는데 도움이 될 수 있습니다. 데이터 가용성 검사를 건너뛰고 싶은 모든 컴포넌트에서 직접 useCurrentUserQuery를 읽는 대신 React Context에서 읽습니다.  그리고 그 컨텍스트는 실제로 첫 번째 검사를 수행하는 부모에 의해 채워집니다:

 

const CurrentUserContext = React.createContext<User | null>(null)

export const useCurrentUserContext = () => {
  return React.useContext(CurrentUserContext)
}

export const CurrentUserContextProvider = ({
  children,
}: {
  children: React.ReactNode
}) => {
  const currentUserQuery = useCurrentUserQuery()

  if (currentUserQuery.isLoading) {
    return <SkeletonLoader />
  }

  if (currentUserQuery.isError) {
    return <ErrorMessage error={currentUserQuery.error} />
  }

  return (
    <CurrentUserContext.Provider value={currentUserQuery.data}>
      {children}
    </CurrentUserContext.Provider>
  )
}

 

여기서는 currentUserQuery를 가져와서 데이터가 존재한다면 (로딩과 오류 상태를 미리 제거하여) React Context에 넣습니다. 

 

function UserNameDisplay() {
  const data = useCurrentUserContext()
  return <div>User: {data.username}</div>
}

 

이를 통해 암시적 종속성(트리의 앞부분에서 데이터를 가져온 것을 알고 있음)을 명시적으로 만들었습니다. 누군가가 UserNameDisplay를 볼 때마다 CurrentUserContextProvider에서 제공된 데이터가 필요하다는 것을 알 수 있습니다. 리팩토링할 때 이점을 염두에 두면 됩니다. 프로바이더가 렌더링되는 위치를 변경하면 해당 컨텍스트를 사용하는 모든 자식에도 영향을 미친다는 것을 알 수 있습니다.  쿼리는 일반적으로 전체 앱에서 전역적이며 데이터가 존재할 수도 있고 존재하지 않을 수도 있기 때문에 컴포넌트가 쿼리만 사용하는 경우에는 알 수 없는 사항입니다.

타입스크립트 만족시키기

타입스크립트는 여전히 이것을 별로 좋아하지 않을 것입니다. 왜냐하면 React Context는 context의 기본값을 제공하는 Provider 없이도 작동하도록 설계되었고, 우리의 경우에는 그 기본값이 null이기 때문입니다. Provider 외부에 있는 상황에서는 useCurrentUserContext가 작동하지 않기를 원하므로 커스텀 훅에 불변성을 추가하겠습니다.

 

export const useCurrentUserContext = () => {
  const currentUser = React.useContext(CurrentUserContext)
  if (!currentUser) {
    throw new Error('CurrentUserContext: No value provided')
  }

  return currentUser
}

 

이렇게 하면 실수로 잘못된 위치에서 useCurrentUserContext에 접근하는 경우 적절한 에러 메시지와 함께 빠르게 실패할 수 있습니다. 그리고 이를 통해 타입스크립트는 커스텀 훅의 currentUser 값을 추론하므로 안전하게 사용할 수 있고 프로퍼티에 접근할 수도 있습니다.

 

상태 동기화

"이거 React Query에서 값 하나 복사해서 또 다른 상태 분배 방법에 넣는 '상태 동기화' 아닌가?"라고 생각할 수도 있습니다.

답하자면, 아닙니다! 진실의 단일 공급원은 여전히 쿼리입니다. 쿼리의 최신 데이터를 항상 반영하는 Provider 말고는 context 값을 변경할 방법이 없습니다. 아무것도 복사되지 않으며 동기화가 어긋날 수도 없습니다. 그리고 React Query의 data를 자식 컴포넌트에 prop으로 전달하는 것은 "상태 동기화"가 아닙니다. 또한 context는 prop drilling과 비슷하므로 "상태 동기화"가 아닙니다.

요청 폭포

모든 것엔 단점이 있고 이 기법도 그렇습니다. 구체적으로는 네트워크 폭포를 일으킬 수 있다는 것입니다. 왜냐하면 컴포넌트 트리의 렌더링이 Provider에서 (일시적으로) 중지되어서 관련 없는 하위 컴포넌트인데도 렌더링이 되지 않고, 그로 인해 해당 컴포넌트의 네트워크 요청이 실행되지 않기 때문입니다.

저는 주로 하위 트리에서 필수적인 데이터에 이 접근법의 사용을 고려합니다. 사용자 정보가 좋은 예인데, 해당 데이터가 없으면 무엇을 렌더링할지 알 수 없기 때문입니다.

 

Suspense

Suspense에 관해 얘기하자면, React Suspense를 사용하여 위와 유사한 아키텍처를 구현할 수 있지만 요청 폭포를 일으킬 수 있다는 동일한 단점이 존재합니다. 저는 이에 대해 이미 #17: Seeding the Query Cache에서 작성한 바 있습니다.

현재 메이저 버전(v4)에는 문제가 하나 있습니다. 쿼리에 suspense: true를 사용하면 data의 타입을 좁힐 수 없다는 것입니다. 쿼리를 비활성화하고 실행되지 않도록 하는 방법이 여전히 존재하기 때문이죠.

하지만 v5에는 컴포넌트가 렌더링 되기만 한다면 데이터가 정의될 것을 보장하는 명시적인 useSuspenseQuery 훅이 생길 것입니다. 이 훅을 사용하면 아래와 같이 할 수 있습니다.

 

function UserNameDisplay() {
  const { data } = useSuspenseQuery(...)
  return <div>User: {data.username}</div>
}

 

그러면 타입스크립트도 행복해할 것입니다. 🎉