React Query

7. Query Options API

yoxxin 2024. 7. 31. 01:10

useQuery나 QueryClient.invailidateQueries 등 다양한 react-query 메서드에 파라미터로 넘겨주는 객체를 Query Options라고 부른다.

// v5전에도 객체 형태로 넣을 수는 있었지만 이처럼 파라미터를 순서대로 넣는 방식이 널리 쓰였다.
- useQuery(
-   ['todos'],
-   fetchTodos,
-   { staleTime: 5000 }
- )
// v5 부터는 객체 형식이 강제되었다.
+ useQuery({
+   queryKey: ['todos'],
+   queryFn: fetchTodos,
+   staleTime: 5000
+ })

이렇게 객체 형태를 파라미터로 넘겨주는 방식은 v5부터 강제되었는데, 어떤 이유 때문일까?

1.  더 나은 추상화 (재사용) 

모든 함수가 동일한 하나의 객체를 받아들인다면, 그 객체를 따로 선언해 두는건 효과적이다.

그렇게 하면 그 객체를 어디서나 재사용할 수 있다.

// query option
const postsQuery = {
  queryKey: ['posts'],
  queryFn: getPosts,
  staleTime: 5000,
};

// query option을 선언해두면 react query의 모든 메서드에서 재사용할 수 있어서 편하다.
useQuery(postsQuery)
useSuspenseQuery(postsQuery)
queryClient.prefetchQuery(postsQuery)
useQueries({ queries: [postsQuery] });

이 패턴은 쿼리의 중요한 추상화이다.

그러나 이 방식은 오타로 인해 TypeScript 문제를 마주칠 수도 있다.

2. Typescript

여기 staleTime을 stallTime으로 잘못 쓴 query option이 있다.

// query option
{
  queryKey: ['todos'],
  queryFn: fetchTodos,
  stallTime: 5000, // staleTime의 오타
}

이때 타입스크립트가 추가프로퍼티를 처리하는 방식을 알아보자.

위에서 선언한 query option을 인라인(리터럴)으로 넣으면 error가 난다.

직접해보기

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)

그러나 따로 선언해서 넣으면 에러가 나지 않는다🥲

직접해보기

따로 선언했을때 에러가 나지 않는 이유는 무엇일까?

런타임에 "추가" 속성인 `todosQuery`의 `stallTime`은 useQuery내부에서 사용되지 않는게 확실하고, 

todosQuery는 다른 곳에서(stallTime을 사용하는 곳에서) 사용될 수 있기 때문에 이 두 가지 상황을 고려해둔 Typescript가 에러를 뱉지 않는다고 이해하면 된다. (이 개념에 대해 더 자세히 파고싶으면 "구조적 타이핑" 키워드로 검색해보자.)

queryOptions 헬퍼 함수

react-query는 query option 사용 시 타입 문제를 해결하기 위해 `queryOptions` 헬퍼 함수를 제공한다.

크게 두 가지 도움을 준다.

1. 타입 에러 체크

2. 타입 추론

타입 에러 체크

런타임때는 아무 일도 하지 않지만 타입 레벨에서 도움을 준다.

queryOptions로 선언하면 stallTime이 잘못된 선언임을 알 수 있다.

Typescript의 구조적 타이핑 때문에 잘못된 속성으로 선언해도 에러가 나지 않던 부분을 잡아준다.

타입 추론

`queryClient.getQueryData(queryKey)`와 같은 함수 리턴 타입을 항상 `unknown`으로 추론되는 문제가 있다.

queryKey만 보고는 리턴 타입을 알 수 없기 때문에 이는 당연하다.

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

따라서 이와 같이 항상 제네릭으로 어설션을 이용해 타입을 선언해주어야 했다.

 

하지만 queryOptions 객체 내부의 `queryKey`를 사용하면 타입이 자동으로 추론된다.

직접 해보기

const postsQueryHelper = queryOptions({
  queryKey: ['posts'],
  queryFn: getPosts,
  staleTime: 5000,
});

const posts = queryClient.getQueryData(postsQueryHelper.queryKey); // Post[] | undefined로 바로 추론

 

이 추론은 `queryOptions.queryKey` 타입에 `queryFn`의 리턴 타입을 넣어준 덕분이다.

(DataTag이라는 타입 선언을 이용함)

queryOptions.queryKey의 타입

내부 코드 보기

3. Query Factory 패턴에 queryOptions 함수를 함께 사용하자.

QueryFunction과 QueryKey를 분리하는 건 실수였다.

`QueryKey`는 `QueryFn`이 실행될때 필요한 모든 정보를 가지고 있어야한다.

그런데 이전에 소개한 Query Factory 패턴은 QueryKey만 따로 선언되어 있었다.

Query Factory 패턴과 queryOptions 함수를 결합해서 타입 안정성을 높이고, 코-로게이션을 통해 DX를 높여보자.

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

위 queryFactory 객체 `todoQueries`에는 키만 있는 속성(`all`, `lists`, `details`)과 `queryOptions`를 사용한 쿼리 객체가 함께 있다.

 

키만 있는 속성은 키 계층 구조 구축(all > lists > list)을 위함이고 또한 query invalidatation(무효화)도 할 수 있다.

`queryOptions`를 사용한 쿼리 객체는 useQuery와 함께 사용하거나 invalidatation 시에도 사용할 수 있다.

사용 예시

// apis.ts
const todos = ['운동하기', '요가하기'];

export const getTodos = (): Promise<string[]> => {
  return new Promise((resolve) => resolve(todos));
};

export const postTodo = async (newTodo: string) => {
  todos.push(newTodo);

  return new Promise((resolve) => resolve('post complete'));
};

// QueryFactory.ts
export const todoQueries = {
  all: () => queryOptions({ queryKey: ['todos'], queryFn: getTodos }),
};

/**
 * Query Factory 패턴과 queryOptions을 이용해 useQuery, invalidateQueries를 사용하는 예제
 */
const App24 = () => {
  const [value, setValue] = useState('');
  // factory에서 선언한 queryOptions로 만든 쿼리 객체 이용해 useQuery 호출
  const { data: todos } = useQuery(todoQueries.all());
  const { mutate: _postTodo } = useMutation({
    mutationFn: postTodo,
    // todoQueries에서 가져온 queryKey로 invalidate
    onSuccess: () => queryClient.invalidateQueries({ queryKey: todoQueries.all().queryKey }),
  });

  const addTodo = () => {
    if (!value) return;
    _postTodo(value);
    setValue('');
  };

  return (
    <>
      <div className="flex gap-2">
        <input
          type="text"
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder="새로운 투두 입력"
        />
        <button onClick={addTodo}>
          투두 추가하기
        </button>
      </div>
      {todos?.map((todo, index) => (
        <div key={index}>{todo}</div>
      ))}
    </>
  );
};

실행 예시


지금까지는 useQuery를 감싼 커스텀훅을 선언해놓고, 그걸 import 해서 사용했었다.

이렇게 감싸서 사용했었다

이게 문제가, 내부에 useSuspenseQuery로 선언해둬서 useQuery를 사용하고 싶을땐 따로 선언해야한다는 점이다.

이제 react-query를 사용할 땐 queryOptions + Query Factory 패턴을 이용해봐야겠다.

그럼 사용처에서 useQuery든 useSuspenseQuery든 골라서 사용할 수 있다!

괜찮은듯? 물론 위 `useWritingEditInfoQuery`처럼 각 쿼리별 옵션이 많아져서 팩토리가 뚱뚱해지면 나눠야 하겠지만

 

+ Mutation은 쓰던대로 커스텀훅으로 감싸서 사용하면 됨

useMutation 사용예시


위 글은 https://tkdodo.eu/blog/the-query-options-api 를 읽으며 나름대로 정리한 글입니다.