React Query and TypeScript

content-logo

Table of Contents

제네릭 (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로 올바르게 유형화되고 errorError|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의 타입입니다. queryFnqueryKey를 전달하는 경우에만 중요합니다.

또한 보시는바와 같이 이 제네릭은 모두 기본값을 갖습니다. 따라서 아무런 값도 제공하지 않는다면 타입스크립트는 기본값을 대신 사용할 것입니다. 이는 자바스크립트의 기본 파라미터 기능과 꽤 동일하게 작동합니다.

// 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를 갖게 될 것입니다. 왜냐하면 axiosfetch가 반환하는 타입이기 때문입니다.

// 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를 사용할 때 저는 구조분해할당을 거의 사용하지 않습니다. 먼저 dataerror같은 이름은 꽤 범용적이므로 (의도적으로 그렇게 만들어져있습니다.) 대부분의 경우 이름을 바꿔서 사용할 것입니다. 전체 객체를 유지하면 데이터가 무엇인지 또 어디서 발생한 에러인지와 같은 컨텍스트를 유지할 수 있습니다. 또한 구조분해할당을 사용할 경우 타입스크립트가 status 필드 또는 status 불리언 값을 사용할 때 타입을 좁히는 데 도움이 되지 않습니다.

// type-narrowing

const { data, isSuccess } = useGroups();
if (isSuccess) {
  // 🚨 데이터는 여전히 `Group[] | undefined` 타입일 것입니다.
}

const groupsQuery = useGroups();
if (groupsQuery.isSuccess) {
  // ✅ groupsQuery.data 는 `Group[]`가 될 것입니다.
}

이는 React Query와는 아무런 상관이 없고 그저 타입스크립트의 작동 방식입니다. 이 동작에 대해서는 @danvdk 가 잘 설명해줬습니다.

Dan Vanderkam on Twitter / X

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)
}

기술적인 측면에서 보면 타입스크립트가 옳습니다. idundefined일 수 있습니다. enabled 옵션은 어떤 종류의 타입 좁히기도 수행하지 않습니다. 또한 useQuery에서 반환하는 refetch 메소드를 직접 호출하는 등의 방식으로 enabled 옵션을 우회할 수 있습니다. 이 경우, id는 정말로 undefined일 수 있습니다.

가장 좋은 방법은 non-null assertion operator을 좋아하지 않는다면, idundefined 일 수 있음을 받아들이고 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,
})

만약 fetchGroupsGroupResponse를 반환하면, 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으로 추론됩니다. 왜냐하면 queryKeyunknown 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');
      },
    },
  },
});

저는 이러한 사례가 unknownany 대비 왜 좋은 타입인지 (그리고 왜 덜 사용되는지)를 꽤 잘 보여준다고 생각합니다. 최근에는 제 최애 타입이 되었는데, 이는 또 다른 블로그 포스트 주제가 될 것입니다. 😊