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. 타입 추론
타입 에러 체크
런타임때는 아무 일도 하지 않지만 타입 레벨에서 도움을 준다.
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이라는 타입 선언을 이용함)
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은 쓰던대로 커스텀훅으로 감싸서 사용하면 됨
위 글은 https://tkdodo.eu/blog/the-query-options-api 를 읽으며 나름대로 정리한 글입니다.
'React Query' 카테고리의 다른 글
9. Mutation 이후 전체 Query를 invalidation 하기 (0) | 2024.08.30 |
---|---|
8. 타입 안전한 React Query (0) | 2024.08.06 |
6. React Query가 필요한 이유 (0) | 2024.07.21 |
5. React Query의 Status (0) | 2024.02.21 |
4. 효율적으로 React Query Key 선언하기 (5) | 2024.02.14 |