(번역) React Query and TypeScript
Table of Contents
본문
TkDodo의 React Query and TypeScript를 번역한 문서입니다.
타입스크립트는 🔥입니다. - 이제는 프론트엔드 커뮤니티에서 흔한 이해관계처럼 보입니다. 많은 개발자들이 라이브러리가 타입스크립트로 작성되거나, 아니면 최소한 좋은 타입 정의를 제공하기를 원합니다. 저는 만약 라이브러리가 타입스크립트로 작성되었다면, 타입 정의는 최고의 문서라고 생각합니다. 타입 정의는 구현을 정확하게 반영하기 때문에 이는 절대로 틀린 말이 아닙니다. 저는 보통 API 문서를 읽기 전에 타입 정의를 먼저 살펴봅니다.
React Query는 처음에 v1에서는 자바스크립트로 작성되었고, v2에서 타입스크립트로 재작성 되었습니다. 이는 React Query가 이제 타입스크립트 사용자들을 아주 잘 지원한다는 점을 의미합니다.
그러나 타입스크립트로 작업할 때는 React Query가 동적이고 의견이 없는 방식으로 동작하기 때문에 몇 가지 “갓챠”가 있습니다. 여러분의 경험을 향상시키기 위해 하나씩 살펴보시죠.
제네릭 (Generics)
React Query는 제네릭을 많이 사용합니다. React Query 자체가 데이터를 불러오는 것이 아니고, 전달받은 api가 반환하는 데이터의 타입을 알지 못하기 때문에 제네릭은 매우 중요합니다.
공식 문서의 타입스크립트 섹션은 매우 광범위하지 않으며, useQuery
를 호출할 때 제네릭을 통해 기대값을 명시적으로 지정하라고 적혀있습니다.
// explicit-generics
function useGroups() {
return useQuery<Group[], Error>({
queryKey: ['groups'],
queryFn: fetchGroups,
});
}
💡 Update
문서가 업데이트 되었으며 이러한 패턴을 더이상 우선적으로 장려하지 않습니다.
시간이 지나면서 React Query는 useQuery
훅에 더 많은 제네릭을 추가해왔습니다. (이제는 총 4개가 있습니다.) 왜냐하면 더 많은 기능이 추가되었기 때문입니다. 위의 코드는 작동하며 커스텀 훅의 data
속성이 Group[]|undefined
로 올바르게 유형화되고 error
가 Error|undefined
로 유형화 되는 것을 보장합니다. 그러나 더 고급 사용 사례에서, 특히 다른 2개의 제네릭이 필요한 경우에는 이렇게 작동하지 않습니다.
4개의 제네릭 (The four Generics)
useQuery
훅은 현재 다음과 같이 정의되어있습니다.
// useQuery
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
여기에는 많은 일이 일어나고 있습니다. 하나씩 살펴보시죠.
TQueryFnData
:queryFn
에서 반환되는 타입입니다. 위의 예시에서는Group[]
입니다.TError
:queryFn
에서 반환될 수 있는 에러의 타입입니다. 예시의Error
입니다.TData
:data
속성이 결과적으로 갖게 될 타입입니다.select
옵션을 사용할 경우에만 중요합니다. 왜냐하면data
속성은queryFn
에서 반환하는 것과는 다르기 때문입니다. 그게 아니라면queryFn
에서 반환하는 것을 기본값으로 갖습니다.TQueryKey
:queryKey
의 타입입니다.queryFn
에queryKey
를 전달하는 경우에만 중요합니다.
또한 보시는바와 같이 이 제네릭은 모두 기본값을 갖습니다. 따라서 아무런 값도 제공하지 않는다면 타입스크립트는 기본값을 대신 사용할 것입니다. 이는 자바스크립트의 기본 파라미터 기능과 꽤 동일하게 작동합니다.
// default-parameters
function multiply(a, b = 2) {
return a \* b;
};
multiply(10); // ✅ 20
multiply(10, 3); // ✅ 30
타입 추론 (Type Inference)
타입스크립트는 어떤 유형이어야 하는지를 자체적으로 추론하게 (또는 결정하게) 둘 때 가장 잘 작동합니다. 코드의 작성을 쉽게 해줄 뿐만 아니라 (왜냐하면 모든 타입을 작성할 필요가 없기 때문이죠 😅) 가독성도 높혀줍니다. 많은 상황에서 타입 추론은 코드를 자바스크립트와 같이 작성할 수 있도록 해줍니다. 타입 추론의 간단한 예시는 다음과 같습니다.
// type-inference
const num = Math.random() + 5; // ✅ `number`
// 🚀 greeting과 greet의 결과물은 string이 될 것입니다.
function greet(greeting = 'ciao') {
return `${greeting}, ${getName()}`;
}
일반적으로 제네릭도 사용처에서 타입 추론이 될 수 있습니다. 아주 멋지죠. 물론 수동으로 타입을 전달할 수도 있지만, 많은 경우에서, 그럴 필요는 없습니다.
// generic-identity
function identity<T>(value: T): T {
return value;
}
// 🚨 제네릭을 제공할 필요가 없습니다.
let result = identity<number>(23);
// ⚠️ 아니면 결과를 명시할 수 있습니다.
let result: number = identity(23);
// 😎 `string` 으로 타입이 정확히 추론됩니다.
let result = identity('react-query');
Partial 타입 Argument 추론 (Partial Type Argument Inference)
타입스크립트는 …을 아직 지원하지 않습니다. (이에 관한 오픈 이슈를 참고하세요) 이는 기본적으로 하나의 제네릭을 제공하면 모든 제네릭을 제공해야 한다는 것을 의미합니다. 그러나 React Query는 제네릭에 대한 기본값을 갖고 있기 때문에, 이 기본값이 사용된다는 사실을 즉시 인지하지 못할 수 있습니다. 이로 인한 에러 메시지는 상당히 난해할 수 있습니다. 실제로 이로 인한 문제가 발생하는 예제를 살펴봅시다.
// default-generics
function useGroupCount() {
return useQuery<Group[], Error>({
queryKey: ['groups'],
queryFn: fetchGroups,
select: (groups) => groups.length,
// 🚨 Type '(groups: Group[]) => number' is not assignable to type '(data: Group[]) => Group[]'.
// Type 'number' is not assignable to type 'Group[]'.ts(2322)
});
}
3번째 제네릭을 전달하지 않았기 때문에, 기본값인 Group[]
가 사용되었는데, select
함수에서 number
를 반환했습니다. 3번째 제네릭을 전달하여 간단히 해결할 수 있습니다.
// third-generic
function useGroupCount() {
// ✅ 고쳐졌습니다.
return useQuery<Group[], Error, number>({
queryKey: ['groups'],
queryFn: fetchGroups,
select: (groups) => groups.length,
});
}
Partial 타입 Argument 추론이 없는 한, 현재 가능한 것들을 사용해야 합니다.
그렇다면 어떤 대안이 있을까요?
모든 것을 추론 (Infer all the things)
우선 제네릭을 아예 전달하지 않고 타입스크립트가 무엇을 해야할지 판단하도록 냅둬봅시다. 이를 작동하도록 하기 위해서, queryFn
가 좋은 반환 타입을 갖도록 해야합니다. 물론, 명시적인 리턴 타입이 없이 해당 함수를 인라인으로 사용하면 any
를 갖게 될 것입니다. 왜냐하면 axios
나 fetch
가 반환하는 타입이기 때문입니다.
// inlined-queryFn
function useGroups() {
// 🚨 데이터는 `any`가 될 것입니다.
return useQuery({
queryKey: ['groups'],
queryFn: () => axios.get('groups').then((response) => response.data),
});
}
만약 (저처럼) api 레이어를 쿼리로부터 분리한 채로 유지하고 싶다면, 암시적인 any를 피하기 위해 어쨌든 타입 정의를 추가해야 합니다. 그래야 React Query가 나머지를 추론할 수 있습니다.
// inferred-types
function fetchGroups(): Promise<Group[]> {
return axios.get('groups').then((response) => response.data);
}
// ✅ 데이터는 `Group[] | undefined` 가 될 것입니다.
function useGroups() {
return useQuery({ queryKey: ['groups'], queryFn: fetchGroups });
}
// ✅ 데이터는 `number | undefined` 가 될 것입니다.
function useGroupCount() {
return useQuery({
queryKey: ['groups'],
queryFn: fetchGroups,
select: (groups) => groups.length,
});
}
이 접근 방식의 장점은 다음과 같습니다.
- 수동으로 제네릭을 지정하지 않아도 됩니다.
- 3번째 제네릭 (select)와 4번째 제네릭 (QueryKey)가 필요한 경우에 적합합니다.
- 더 많은 제네릭이 추가되어도 계속 작동할 것입니다.
- 코드는 덜 혼란스러울 것이며 자바스크립트 코드인 것 처럼 보일 것입니다.
에러는 어떤가요? (What about error?)
아마도 에러는 어떤가요? 라고 물어볼 수도 있을 것 같아요. 기본적으로, 제네릭이 없다면, 에러는 unknown
으로 추론될 것입니다. 버그처럼 들릴 수도 있을 것 같아요. 왜 Error
가 아닐까요? 이는 의도된 처리입니다. 왜냐하면 자바스크립트에서는 에러로 아무것이나 던질 수 있거든요. 에러의 타입이 꼭 Error
여야만 하지는 않습니다.
// totally-legit-throw-statements
throw 5;
throw undefined;
throw Symbol('foo');
React Query는 Promise를 반환하는 함수에 대해 아무런 책임을 갖고 있지 않기 때문에, 그 함수가 반환할 에러의 타입도 알지 못합니다. 따라서 unknown
을 갖는게 맞습니다. 타입스크립트가 여러 개의 제네릭을 갖는 함수를 호출할 때 일부 제네릭을 건너뛸 수 있도록 허용한다면, (더 많은 정보는 이 이슈를 참고하세요) 더 나은 처리 방법을 구현할 수 있을 것입니다. 그러나 지금으로서는, 제네릭을 전달하지 않고 에러 작업을 해야할 경우, instanceof 검사를 통해 타입을 좁힐 수 있습니다.
// narrow-with-instanceof
const groups = useGroups();
if (groups.error) {
// 🚨 Object is of type 'unknown'.ts(2571) 에러로 인해 동작하지 않을 것입니다.
return <div>An error occurred: {groups.error.message}</div>;
}
// ✅ instanceOf 체크를 통해 타입을 `Error`로 좁힙니다.
if (groups.error instanceof Error) {
return <div>An error occurred: {groups.error.message}</div>;
}
어쨌든 에러가 있는지 확인하기 위해 어떤 식으로든지간에 확인을 해야하기 때문에, instanceof 검사는 그렇게 나쁜 생각처럼 보이지 않습니다. 또한 이 검사는 에러가 런타임에 실제로 에러 메시지를 갖고 있는지도 확인합니다. 이는 타입스크립트가 4.4 릴리즈에서 도입할 예정인 새로운 컴파일러 플래그 useUnknownInCatchVariables
와 궤를 같이합니다. 이로 인해 catch 변수가 any
대신 unknown
이 될 것입니다. (이 곳을 참고하세요.)
타입 좁히기 (Type Narrowing)
React Query를 사용할 때 저는 구조분해할당을 거의 사용하지 않습니다. 먼저 data
와 error
같은 이름은 꽤 범용적이므로 (의도적으로 그렇게 만들어져있습니다.) 대부분의 경우 이름을 바꿔서 사용할 것입니다. 전체 객체를 유지하면 데이터가 무엇인지 또 어디서 발생한 에러인지와 같은 컨텍스트를 유지할 수 있습니다. 또한 구조분해할당을 사용할 경우 타입스크립트가 status 필드 또는 status 불리언 값을 사용할 때 타입을 좁히는 데 도움이 되지 않습니다.
// type-narrowing
const { data, isSuccess } = useGroups();
if (isSuccess) {
// 🚨 데이터는 여전히 `Group[] | undefined` 타입일 것입니다.
}
const groupsQuery = useGroups();
if (groupsQuery.isSuccess) {
// ✅ groupsQuery.data 는 `Group[]`가 될 것입니다.
}
이는 React Query와는 아무런 상관이 없고 그저 타입스크립트의 작동 방식입니다. 이 동작에 대해서는 @danvdk 가 잘 설명해줬습니다.
enabled 옵션을 통한 타입 안정성 (Type safety with the enabled option)
enabled 옵션에 대한 제 ♥️ 를 처음부터 밝혀왔지만, 종속적인 쿼리에서 이 옵션을 사용하고 일부 매개변수가 정의될 때까지 쿼리를 비활성화하려는 경우, 이 옵션은 타입 레벨에서 조금 까다롭게 작용할 수 있습니다.
// the-enabled-option
function fetchGroup(id: number): Promise<Group> {
return axios.get(`group/${id}`).then((response) => response.data);
}
function useGroup(id: number | undefined) {
return useQuery({
queryKey: ['group', id],
queryFn: () => fetchGroup(id),
enabled: Boolean(id),
});
// 🚨 'number | undefined' 타입의 값은 'number' 타입의 매개변수에 할당할 수 없습니다.
// Type 'undefined' is not assignable to type 'number'.ts(2345)
}
기술적인 측면에서 보면 타입스크립트가 옳습니다. id
는 undefined
일 수 있습니다. enabled
옵션은 어떤 종류의 타입 좁히기도 수행하지 않습니다. 또한 useQuery
에서 반환하는 refetch
메소드를 직접 호출하는 등의 방식으로 enabled
옵션을 우회할 수 있습니다. 이 경우, id
는 정말로 undefined
일 수 있습니다.
가장 좋은 방법은 non-null assertion operator을 좋아하지 않는다면, id
가 undefined
일 수 있음을 받아들이고 queryFn
에서 Promise를 reject하는 것입니다. 이것은 약간의 중복을 갖고 있지만, 명시적이고 안전합니다.
// explicit-id-check
function fetchGroup(id: number | undefined): Promise<Group> {
// ✅ id가 `undefined`일 수 있으므로 런타임에서 체크합니다.
return typeof id === 'undefined'
? Promise.reject(new Error('Invalid id'))
: axios.get(`group/${id}`).then((response) => response.data);
}
function useGroup(id: number | undefined) {
return useQuery({
queryKey: ['group', id],
queryFn: () => fetchGroup(id),
enabled: Boolean(id),
});
}
낙관적인 업데이트 (Optimistic Updates)
타입스크립트에서 낙관적인 업데이트를 구현하는 것은 쉬운 일이 아닙니다. 때문에 저희는 공식 문서에 포괄적인 예시를 추가하기로 결정했습니다.
중요한 부분은 최고의 타입 추론을 얻으려면 onMutate
에 전달된 variables
인자의 타입을 명시적으로 지정해야 합니다. 이 부분이 완전히 이해되지는 않지만, 다시 한 번 제네릭의 추론과 관련이 있는 것으로 보입니다. 자세한 내용은 이 댓글을 확인하세요.
useInfiniteQuery
대부분의 경우에서 useInfiniteQuery
에 대한 타입을 지정하는 것은 useQuery
에서 하는 것과 다르지 않습니다. 주목할만한 갓챠는 queryFn
에 전달되는 pageParam
값인데요, any
로 타입이 지정되어 있습니다. 라이브러리에서 더 개선할 수 있을 것으로 확신하지만, 현재는 any
로 되어있기 때문에, 명시적으로 주석을 달아주는 것이 최선일 것 같습니다.
// useInfiniteQuery
****
type GroupResponse = { next?: number; groups: Group[] }
const queryInfo = useInfiniteQuery({
queryKey: ['groups'],
// ⚠️ pageParam을 명시적으로 타입 지정하여 `any`를 오버라이드합니다.
queryFn: ({
pageParam = 0,
}: {
pageParam: GroupResponse['next']
}) => fetchGroups(groups, pageParam),
getNextPageParam: (lastGroup) => lastGroup.next,
})
만약 fetchGroups
가 GroupResponse
를 반환하면, lastGroup
의 타입은 잘 추론되며, 같은 타입을 사용해서 pageParam
에 주석을 달 수 있습니다.
기본 쿼리 함수에 대한 타이핑 (Typing the default query function)
저는 개인적으로 defaultQueryFn을 사용하지 않지만 많은 사람들이 사용하는 것을 알고 있습니다. 전달된 queryKey
를 활용하여 직접 요청 url을 구성하는 깔끔한 방법입니다. queryClient
을 생성할 때 함수를 인라인으로 사용하면, 전달된 QueryFunctionContext
의 유형도 자동으로 추론됩니다. 타입스크립트는 인라인으로 작업할 때 더욱 강력합니다 :)
// defaultQueryFn
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: async ({ queryKey: [url] }) => {
const { data } = await axios.get(`${baseUrl}/${url}`);
return data;
},
},
},
});
이 방식은 동작은 하지만, url
의 타입이 unknown
으로 추론됩니다. 왜냐하면 queryKey
가 unknown Array
이기 때문입니다. queryClient를 생성하는 시점에서, useQuery
를 호출할 때 queryKeys가 어떻게 구성되는지에 대한 절대적인 보장이 없기 때문에 React Query가 할 수 있는 일은 제한적입니다. 이것은 이 매우 동적인 기능의 본질입니다. 이건 사실 그렇게 나쁜 일은 아닙니다. 왜냐하면 이제는 기능이 동작하도록 하기 위해 방어적으로 작업해야하고 런타임 검사를 통해 타입을 좁히는 등의 작업을 해야하기 때문입니다. 예를 들면 다음과 같습니다.
// narrow-with-typeof
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: async ({ queryKey: [url] }) => {
// ✅ url의 타입을 string으로 좁힙니다.
// 따라서 이 기능이 동작할 수 있도록 합니다.
if (typeof url === 'string') {
const { data } = await axios.get(`${baseUrl}/${url.toLowerCase()}`);
return data;
}
throw new Error('Invalid QueryKey');
},
},
},
});
저는 이러한 사례가 unknown
이 any
대비 왜 좋은 타입인지 (그리고 왜 덜 사용되는지)를 꽤 잘 보여준다고 생각합니다. 최근에는 제 최애 타입이 되었는데, 이는 또 다른 블로그 포스트 주제가 될 것입니다. 😊