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에서도 여러 인수가 아니라 객체 하나를 전달할 수 있었습니다. 다만 그다지 널리 알려지지 않았을 뿐이죠. 대부분의 앱에게 breaking change가 된 이유는 공식 문서와 많은 블로그(여기 포함)의 예시들이 모두 이전 API를 사용했기 때문입니다.
그렇다면 이렇게 변경한 이유는 뭘까요?
먼저, 그 모든 오버로딩의 존재가 메인테이너에게는 번거로운 일이고, 사용자에게는 명확하지 않습니다. "왜 똑같은 함수를 여러 방식으로 호출할 수 있는 거예요? 그중에 더 좋은 방식이 있는 건가요?"라고 할 수 있죠. 그래서 API를 간소화하여 처음 시작하는 사람도 쉽게 이해할 수 있도록 하는 게 하나의 목표였습니다. "항상 객체 하나만 전달하라". 이보다 간단하고 확장성이 좋을 순 없죠.
게다가 하나의 객체로 모든 걸 규정하는 추상화는 쿼리 옵션을 여러 함수 간에 공유하고 싶을 때 아주 좋은 걸로 밝혀졌습니다. 저는 이걸 React Query meets React Router을 작성하면서, prefetch와 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'])
를 호출할 때 어떤 타입이 반환될지 알 방법이 없기 때문입니다.
따라서 함수 호출에 타입 매개변수를 제공해 직접 해결할 수 밖에 없습니다.
// 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레포에 머지된 번역 작업물 주소가 더이상 유효하지않다고 나와서 알려드리려 왔습니다!