본문 바로가기

카테고리 없음

[번역] The Query Options API

https://tkdodo.eu/blog/the-query-options-api

 

The Query Options API

v5 brought a new, powerful API, especially if you're using React Query with TypeScript...

tkdodo.eu

 

약 3개월 전에 React Query v5가 출시되었고, 라이브리리 역사상 가장 큰 '획기적인' 변화가 있었다. 이제 모든 함수는 여러개의 인수가 아닌 하나의 객체만 전달받는다. 이 객체는 쿼리를 만드는데 필요한 모든 옵션이 포함되어 있으므로 쿼리 옵션이라고 부른다. 

 

- useQuery(
-   ['todos'],
-   fetchTodos,
-   { staleTime: 5000 }
- )
+ useQuery({
+   queryKey: ['todos'],
+   queryFn: fetchTodos,
+   staleTime: 5000
+ })

 

이는 useQuery 호출뿐만 아니라 쿼리 무효화같은 명령형 작업에도 해당된다. 

 

- queryClient.invalidateQueries(['todos'])
+ queryClient.invalidateQueries({ queryKey: ['todos'] })

 

엄밀히 말하면 이 API는 새로운 것이 아니다. 대부분 함수에 오버로드가 있었기 때문에 v3에서도 이미 여러 인자 대신 객체를 전달할 수 있었다. 다만 그다지 널리 알려지지 않았을 뿐이다. 모든 예제, 문서 및 많은 블로그 게시물(이 글 포함)이 이전 API를 사용했기 때문에 대부분의 앱에서 이 변경은 획기적인 변화였다. 

 

그렇다면 왜 그렇게 했을까? 

 

A better abstraction

우선, 이러한 과부하를 모두 처리하는 것은 메인테이너에게는 번거로운 일이며 사용자에게도 명확하지 않다. 같은 함수를 여러가지 방법으로 호출할 수 있는데 어느것이 다른 것보다 나은가요? 따라서 API를 간소화하여 처음 시작하는 사람도 쉽게 이해할 수 있도록 하는 것이 하나의 목표였다. "항상 하나의 객체 전달"은 간단하면서도 확장성이 뛰어나다. 

 

또한 하나의 객체가 모든 객체를 지배하는 것은 여러 함수 간에 쿼리 옵션을 공유하고자 할 때 매우 좋은 추상화라는 사실이 밝혀졌다. 나는 이 사실을 우연히 react query meets react router라는 아티클을 작성할 때 발견했다. 일반적으로 쿼리를 재사용하는 기본 방법으로 커스텀 훅을 작성할 수 있다. 하지만 프리페칭과 같은 필수 함수 호출이 포함된 경우에는 작동하지 않는다. 그래서 저는 무언가를 생각해냈고 알렉스는 이를 좋은 패턴이라고 언급했다. 

 

모든 함수가 하나의 객체를 받아들이는 동일한 인터페이스를 가지고 있다면, 그 객체를 쿼리 정의로 추상화하는 것은 매우 합리적이다. 일단 그렇게 하면 어디든지 전달할 수 있다. 

 

const todosQuery = {
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
}

// ✅ works
useQuery(todosQuery)

// 🤝 sure
queryClient.prefetchQuery(todosQuery)

// 🙌 oh yeah
useSuspenseQuery(todosQuery)

// 🎉 absolutely
useQueries([{
  queries: [todosQuery]
}]

 

 

지금 생각해보면 이 패턴은 쿼리를 위한 주요 추상화로서 아름답게 느껴졌고, 모든 곳에 적용하고 싶었다. 그런데 한가지 문제가 있었다. 

 

TypeScript

타입스크립트가 초과 프로퍼티를 처리하는 방식은 매우 특별하다. 인라인으로 처리하면 TS는 다음과 같이 된다. 왜 이러는거야, 이건 말이안돼

 

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  stallTime: 5000,
})

 

 

Object literal may only specify known properties, but 'stallTime' does not exist in type 'UseQueryOptions<Todo[], Error, Todo[], string[]>'. Did you mean to write 'staleTime'?(2769)

 

위와 같은 오타를 잡아낼 수 있기 때문에 멋지다. 하지만 패턴에서 제안하는 것처럼 전체 객체를 상수로 추가한다면어떨까? 

 

const todosQuery = {
  queryKey: ['todos'],
  queryFn: fetchTodos,
  stallTime: 5000,
}

useQuery(todosQuery)

 

no.error.

 

런타입에 "추가" 속성인 stallTime은 문제가 되지 않으며 해당 속성이 필요한 컨텍스트에서 해당 객체를 사용할 수 있기 때문에 TS는 이러한 상황에서 상당히 느긋하다. 타입스크립트는 이를 알 수 없다. 그리고 staleTime은 선택사항이므로 이제 이를 전달하지 않는다. 물론 이것은 "유효"하지만 우리가 기대하는 것과는 다르며 찾는데 비용이 많이 드는 실수가 될 수 있다. 

queryOptions

그래서 v5에 queryOptions라는 type 안전 도우미 함수를 도입했다. 런타임에는 아무 작업도 수행하지 않는다.

 

export function queryOptions(options) {
  return options
}

 

 

하지만 type level에서는 위의 오타 문제를 해결할 뿐만 아니라 쿼리 클라이언트의 다른 부분을 보다 type에 안전하게 만드는데 도움이 될 수 있는 진정한 강자이다. 

 

queryClient.getQueryData와 이와 유사한 함수에 대해 항상 약간 성가신 점이 하나 있다. 타입 수준에서 unknown을 반환한다는 것이다. 그 이유는 react query에는 미리 중앙화된 타입 정의가 없기 때문에 queryClient.getQueryData(['todos'])를 호출하면 라이브러리가 어떤 타입이 반환될지 알 수 있는 방법이 없다. 

 

함수 호출에 타입 매개변수를 제공함으로써 스스로를 도울 수 밖에 없다.

 

const todos = queryClient.getQueryData<Array<Todo>>(['todos'])
//    ^? const todos: Todo[] | undefined

 

 

명확하게 말하자면, 이것은 타입 어설션을 사용하는 것보다 안전하지 않지만 적어도 정의되지 않은 것이 우리를 위해 유니온에 추가된다. fetchTodos 엔드포인트가 반환하는 것을 리팩터링하면 여기에 새 type에 대한 알림이 표시되지 않는다. 

 

하지만 이제 쿼리키와 쿼리Fn을 함께 배치하는 함수가 있으므로 쿼리 Fn의 type을 연결하고 쿼리 키에 '태그'를 붙일 수 있다. 쿼리 옵션을 통해 생성한 쿼리키를 getQueryData에 전달하면 어떤 일이 발생하는지 살펴보자:

 

const todosQuery = queryOptions({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
})

const todos = queryClient.getQueryData(todosQuery.queryKey)
//    ^? const todos: Todo[] | undefined

 

이것은 타입스크립트 마법이다. todosQuery.queryKey를 보면 문자열 배열일 뿐만 아니라 쿼리 Fn이 반환하는 내용에 대한 정보도 포함되어 있음을 알 수 있다. 

 

(property) queryKey: string[] & {
  [dataTagSymbol]: Todo[];
}

 

그런 다음 해당 정보를 getQueryData(및 setQueryData와 같은 다른 함수에서도)가 읽어 타입을 추론한다. 이렇게 하면 React Query에 완전히 새로운 수준의 타입 안정성을 제공하는 동시에 쿼리 옵션을 더 쉽게 사용할 수 있다. DX의 큰 승리이다. 

 

Query Factories

 

따라서 이 패턴과 쿼리 옵션 헬퍼를 모든곳에 사용하고 싶다. 심지어 추상화를 위해 사용자 정의 훅이 첫 번째 선택이 되지 않는 지점까지 나아가고 싶다. 커스텀훅이 하는 일이 다음과 같다면 조금 무의미해보인다. 

const useTodos = () => useQuery(todosQuery)

 

컴포넌트에서 useQuery를 직접 호출하는 것은 잘못된 것이 아니며, 특히 때때로 useSuspenseQuery와 혼합하려는 경우 더욱 그렇다. 물론 useMemo를 사용한 추가메모화 처럼 많은 기능을 수행한다면 여전히 추가해도 괜찮다. 하지만 이전처럼 바로 추가하지는 않을 것이다. 또한 저는 이제 쿼리키 팩토리를 조금 다른 시각으로 바라보고 있다.

 

Separating QueryKey from QueryFunction was a mistake

const todoQueries = {
  all: () => ['todos'],
  lists: () => [...todoQueries.all(), 'list'],
  list: (filters: string) =>
    queryOptions({
      queryKey: [...todoQueries.lists(), filters],
      queryFn: () => fetchTodos(filters),
    }),
  details: () => [...todoQueries.all(), 'detail'],
  detail: (id: number) =>
    queryOptions({
      queryKey: [...todoQueries.details(), id],
      queryFn: () => fetchTodo(id),
      staleTime: 5000,
    }),
}
 

쿼리키는 쿼리Fn에 대한 종속성을 정의이하며, 그 안에서 사용하는 모든 것은 키에 들어가야한다. 그렇다면 왜 키를 한 중앙에 정의하고 함수는 커스텀 훅에서 멀리 떨어져 있는걸까? 하지만 두가지 패턴을 결합하면 타입 안전, 코로케이션, DX라는 세가지 장점을 얻을 수 있다. 

 

여기에는 계층 구조 구축 및 쿼리 무효화에 사용할 수 있는 키 전용 항목과 쿼리 옵션 헬퍼로 만든 전체 쿼리 객체가 혼합되어있다.