Photo by Dan Scott
TkDodo의 The Query Options API를 번역한 글입니다.
약 3개월 전에 React Query v5가 출시되면서 라이브러리 역사상 가장 큰 "breaking" change(하위 호환성이 손상되는 변경사항)가 있었습니다. 이제 React Query의 모든 함수는 인수 여러 개가 아니라 객체 하나만 전달받습니다. 이 객체를 Query Options라고 부르는데, 쿼리 생성에 필요한 모든 옵션이 들어있기 때문입니다.
// DIFF - useQuery( - ['todos'], - fetchTodos, - { staleTime: 5000 } - ) + useQuery({ + queryKey: ['todos'], + queryFn: fetchTodos, + staleTime: 5000 + })
이건 useQuery
호출뿐만 아니라 쿼리 무효화 같은 명령형 작업에도 해당됩니다.
// DIFF - queryClient.invalidateQueries(['todos']) + queryClient.invalidateQueries({ queryKey: ['todos'] })
엄밀히 말해 이 API는 새로운 게 아닙니다. React Query 함수 대부분이 오버로딩 되어있었기에 이미 v3에서도 여러 개의 인수 대신 객체 하나를 전달할 수 있었습니다. 다만 그다지 널리 알려지지 않았을 뿐이죠. 공식 문서와 많은 블로그(여기 포함) 글의 예제들이 전부 이전 API를 사용했기 때문에 이건 대부분의 앱에게 breaking change였습니다.
그러면 저희는 왜 이렇게 바꾼 걸까요?
먼저, 그 모든 오버로딩은 메인테이너에게는 번거로운 작업이며 사용자에게는 명확하지가 않습니다. "왜 같은 함수를 여러 방식으로 호출할 수 있는 거예요? 다른 것보다 나은 방식이 있는 건가요?" 그러므로 API를 간소화해서 처음 시작하는 사람도 쉽게 이해할 수 있도록 하는 게 하나의 목표였습니다. "항상 객체 하나만 전달하라". 이보다 간단하고 확장성이 좋을 순 없습니다.
게다가 모든 걸 규정하는 하나의 객체는 여러 함수 간에 쿼리 옵션을 공유하기 아주 좋은 추상화인 것으로 드러났습니다. 저는 이걸 React Query meets React Router을 작성했을 때, 프리페칭과 useQuery
호출 간에 쿼리 옵션을 공유하고자 한 지점에서 "우연히" 발견했습니다. 지금은 보통 쿼리를 재사용하기 위한 첫번째 방식으로 그냥 커스텀 훅을 만들 수도 있습니다. 하지만 prefetching
처럼 명령형 함수 호출이 관련된 경우엔 그게 안 됩니다. 그래서 저는 무언가를 제시했고, Alex는 그게 좋은 패턴이라고 언급해 줬습니다.
R. Alex Anderson
저는 이 React Query 패턴을 @TkDodo가 React Router에 대해 작성한 블로그 글에서 처음 봤습니다.
훌륭하네요 👏
https://dev.to/tkdodo/react-query-meets-react-router-38f3
모든 함수의 인터페이스가 같고, 그 인터페이스가 객체 하나를 받는다면, 해당 객체를 쿼리 정의로 추상화하는 것이 아주 합리적입니다. 일단 존재하기만 하면 어디로든 전달할 수 있죠.
// todos-query
const todosQuery = {
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
}
// ✅ 됩니다
useQuery(todosQuery)
// 🤝 그럼요
queryClient.prefetchQuery(todosQuery)
// 🙌 오 예
useSuspenseQuery(todosQuery)
// 🎉 완전 되죠
useQueries([{
queries: [todosQuery]
}]
지나고 보니 이 패턴이 쿼리의 주요한 추상화로 알맞게 느껴져서 모든 곳에 적용하고 싶었습니다. 그런데 딱 하나 문제가 있었습니다.
타입스크립트가 초과 프로퍼티를 처리하는 방식은 좀 별납니다. 인라인으로 초과 프로퍼티를 넣으면 타입스크립트는 이럴 겁니다. "너 왜 이래? 이건 말도 안 되잖아. 나 에러 뱉을래."
// inlined-objects
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)
좋네요, 위에 보이는 오타를 잡아내잖아요. 하지만 패턴이 제시하는 것처럼 객체 전체를 상수로 추상화하면 어떻게 될까요?
// no-error
const todosQuery = {
queryKey: ['todos'],
queryFn: fetchTodos,
stallTime: 5000,
}
useQuery(todosQuery)
에러가 없네요. 🙈
타입스크립트는 이런 상황에 상당히 관대합니다. 런타임에 "여분의" 프로퍼티인 stallTime
은 아무런 해도 끼치지 않으며, 해당 프로퍼티가 필요한 상황에서는 쿼리 옵션 객체를 사용할 것이기 때문입니다. 타입스크립트는 이걸 알 수가 없습니다. 그리고 staleTime
이 옵셔널이기 때문에 지금은 전달하지도 않고 있죠. 물론 이건 "유효"하긴 하지만 우리가 기대하는 게 아니고 찾기 힘든 실수가 될 수 있습니다.
이게 바로 v5에 queryOptions
라는 타입-안전한 헬퍼 함수를 도입한 이유입니다. 이 함수는 런타임에는 아무 것도 하지 않습니다.
// queryOptions
export function queryOptions(options) {
return options
}
그러나 타입 레벨에서는 위에서 본 오타 문제를 해결할 뿐만 아니라(수정된 플레이그라운드), queryClient
의 다른 부분들을 좀 더 타입-안전하게 만드는 걸 도와줄 수도 있는 진정한 동력원입니다.
React Query에서 queryClient.getQueryData
와 그 비슷한 함수들이 살짝 성가시게 하는 점 하나가 항상 있었는데, 타입 레벨에서 unknown
을 반환한다는 겁니다. 그 이유는 React Query에는 사전(up-front) 정의와 중앙화된 정의가 없어서 queryClient.getQueryData(['todos'])
를 호출할 때 React Query가 반환값 타입을 알 수 있는 방법이 없기 때문입니다.
따라서 함수 호출에 타입 매개변수를 넣어서 스스로 해결할 수 밖에 없습니다.
// manual-type-parameter
const todos = queryClient.getQueryData<Array<Todo>>(['todos'])
// ^? const todos: Todo[] | undefined
분명히 하자면, 이게 타입 단언보다 안전한 건 전혀 아니지만 적어도 저희를 위해 유니온 타입에 undefined
가 추가될 겁니다. 그래도 fetchTodos
엔드포인트의 반환값을 리팩터링 했을 때 여기서 새로운 타입을 알려주는 일은 없겠죠. 😔
하지만 이제 queryKey
와 queryFn
을 같이 두는(co-locate) 함수가 있으니, queryFn
의 타입과 연계해서 queryKey
를 "태그"할 수 있습니다. queryOptions
로 생성된 queryKey
를 getQueryData
에 전달하면 어떻게 되는지 보세요.
// tagged-query-key
const todosQuery = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
})
const todos = queryClient.getQueryData(todosQuery.queryKey)
// ^? const todos: Todo[] | undefined
🤯
이건 세상에 하나뿐인 Mateusz Burzyński가 기여한, 그야말로 타입스크립트 마법입니다. todosQuery.queryKey
를 보면 문자열 배열 뿐만 아니라 queryFn
반환값의 정보도 들어있음을 알 수 있습니다.
// dataTagSymbol
(property) queryKey: string[] & {
[dataTagSymbol]: Todo[];
}
그리고 getQueryData
(와 setQueryData
같은 다른 함수들)이 이 정보를 읽어서 우리를 위해 타입을 추론해줄 겁니다. 이건 React Query에 완전히 새로운 수준의 타입 안전성을 제공하는 동시에 쿼리 옵션을 더 쉽게 재사용할 수 있도록 해줍니다. DX 대성공이네요. 🚀
저는 이 패턴과 queryOptions
헬퍼를 모든 곳에서 사용하고 싶습니다. 심지어 추상화를 하기 위한 첫번째 선택지가 커스텀 훅이 되지 않는 데까지 나아가고 싶습니다. 커스텀 훅이 하는 게 아래 코드가 전부라면 커스텀 훅은 조금 무의미해 보이죠.
// custom-hooks
const useTodos = () => useQuery(todosQuery)
컴포넌트에서 useQuery
를 직접 호출하는 건 전혀 잘못된 게 아니고, 가끔 useSuspenseQuery
와 조합하려는 경우가 특히나 그렇습니다. 훅이 useMemo
를 사용한 메모이제이션처럼 뭔가를 더 한다면 당연히 추가해도 괜찮습니다. 하지만 저는 그런 것에 예전만큼 바로 손이 가진 않을 거예요.
그리고 저는 이제 Query Key Factories를 조금 다르게 바라봅니다. 이런 깨달음을 얻게 되었거든요.
QueryFunction에서 QueryKey를 분리하는 건 실수였다
queryKey
는 queryFn
의 의존성을 정의합니다. queryFn
안에서 사용되는 모든 것은 키가 되어야 하죠. 그렇다면 키는 한 곳에 모아 정의해놓고 함수는 키와 멀리 떨어진 커스텀 훅 내부에 둘 이유가 있나요?
그러지 말고 두 패턴을 결합1하면 타입-안전성, 같이 두기(co-location), 뛰어난 DX라는 세 가지 장점을 모두 얻을 수 있습니다. 🚀
Query.gg 🔮
이건 제가 ui.dev와 함께 작업 중인 새로운 공식 React Query 강의에서 가르치고 있는 패턴 중 하나입니다. 이 강의에서는 React Query가 내부적으로 어떻게 동작하는지 기본 원리를 이해하고, 확장 가능한 React Query 작성법을 배울 수 있습니다. 지금까지 제가 만든 콘텐츠가 마음에 드셨다면 query.gg도 마음에 드실 거예요.
쿼리 팩토리의 예시는 이런 식이 되겠네요.
// query-factory
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,
}),
}
여기엔 계층 생성과 쿼리 무효화에 사용할 수 있는 키 전용 엔트리와 queryOptions
헬퍼로 생성된 완전한 쿼리 객체의 혼합체가 들어있습니다.
1) Query Options
+ Query Key Factory
= Query Factory
- 옮긴이
안녕하세요! TKDodo레포에 머지된 번역 작업물 주소가 더이상 유효하지않다고 나와서 알려드리려 왔습니다!