[npm] react-query
此篇為各筆記之整理,非原創內容,資料來源可見下方連結與文後參考資料:
- React Query: Server State Management in React @ Udemy
- Mastering Mutations in React Query @ TkDodo's blog
- TanStack Query
練習用 repo @ gitlab
部分 API 或用法在 React Query v4 可能已不適用,詳情參考:Migrating to React Query 4。
Concept
React Query 的作用(How)
React-query 會介於 React 和 API Server(Data) 之間,所有透過 React 向 Server 發送的請求都會先經過 react-query。
react-query 在 React 和 server 之間扮演了資料管理和 cache 的角色,開發者需要告知 react-query 什麼時候要更新 cache、什麼時候要重新向 server 發送 API 請求。
解決了什麼(Why)
Client State vs. Server State
- client state:和瀏覽器本身較相關的狀態,例如,使用者選擇的 theme、language 等
- server state:資料本身保存在伺服器,但需要用來顯示在 UI 上的
status vs. fetchStatus
Why two different states @ TanStack Query v4
使用 React Query v4 拉取資料是,會有兩個狀態:
status
:指的是 data 的狀態,用來判斷「資料是否存在」,其狀態型別為QueryStatus
,包含:loading
、error
、success
fetchStatus
:指的是queryFn
的狀態,用來判斷「queryFn 是否有在執行」,其狀態型別為FetchStatus
,包含:fetching
、pause
、idle
。
staleTime vs. cacheTime
staleTime — 和重新拉取資料的時間有關
staleTime
指的是資料多久後算過期(stale),決定了什麼時候要向 server 發送請求以取得最新的資料,過期後的 query 才會需要要向 server 發送請求以取得最新的資料,預設是 0。
react-query 並不會在該 query stale 後就立即向 server 發送請求去取得最新的資料,還需要特定的 trigger 下才會執行 refetch 的動作,這些 trigger 像是 window 再次被 focus 時、元件重新 mount 等等,可以參考 useQuery API 中對於 refetch 的說明。
cacheTime:inactive query 的資料多久後要清除 — 和記憶體管理有關
如果 react-query 中有 cache 的資料,則 useQuery 會先回傳這個 cache 來呈現在 UI 上,但要留意實際使用時如果畫面上先看到舊資料(cache)才瞬間更新成新資料時適不適當。這些 cache 被清除的時間則取決於 cacheTime
的設定。
當原本的 queryKey
並沒有在當前 UI 上被 useQuery()
使用到時,這個 query 會馬上進到 inactive
的狀態。但因為 inactive query 中的資料仍有可能再不久後被使用到,因此並不會馬上就把這個 query 及其取得的資料給清除。
開發者可以透過 cacheTime
的設定來決定 inactive query 要在多久後被清除(garbage collected),一旦超過所設定的 cache time,這個 query 及其 data 都會被清除(garbage collected),預設是 5 mins。
cacheTime 的設定和資料是否要在向 server 請求一次無關,只和 cache 的資料要保存多久有關。
cacheTime 理應要比 staleTime 來的更久,如果 cacheTime 小於 staleTime 最好調整一下設定。
在 react-query 的 devtool 中可以看到那些 query 是屬於 inactive 的,這些 inactive query 所取得的資料會在過了設定的 cacheTime
後被清除:
query status
useQuery
的 status 一共包含:
- idle
- error
- loading
- success
isFetching vs. isLoading
isFetching
是送出 API 請求但該請求還沒被 resolvedisLoading
指的是除了正在 fetching 之外,同時沒有 cached data,也就是 "isFetching + no cached data"。它是isFetching
的子集合(subset),所以 isLoading 時,一定也會是 isFetching。
cache level and observer level
Placeholder and Initial Data in React Query @ TkDodo's blog
在 react-query 中,不同的設定會作用在不同的層級上,可以簡單分成 cache level 和 observer level:
Global:以 cache level 來說,每一個 query key 都會對應到一組 cache entry,這是「一對一的關係」,透過 query key 就可以讓我們在整個 App 中讀取到這些 query data。useQuery 中的一些設定就是直接作用在 cache level 的,例如 stateTime
和 cacheTime
,這些設定就是會跟著這個 cache entry(全域都一樣)。
Local:另一個是 observer level,每當使用一次 useQuery
就會建立一個 observer,每一個 observer 同樣會根據 query key 對應到一組 cache entry,但這是「多對一的關係」,也就是說,可以有多個 observer 都在監控同一組 cache entry(即,使用 useQuery
且帶入相同的 query key),每當資料有改變的時候,就會觸發元件重新轉譯(re-render)。useQuery 中的一些設定則是作用在 observer level,例如,select
或 keepPrevious
,這些設定會隨著不同的 useQuery 而有不同。
Getting Started
建立 QueryClient
+import { QueryClient, QueryClientProvider } from "react-query";
+const queryClient = new QueryClient();
function App() {
return (
+ <QueryClientProvider client={queryClient}>
<div className="App">
<h1>React Query</h1>
</div>
+ </QueryClientProvider>
);
}
整合 devtool
import { QueryClient, QueryClientProvider } from "react-query";
+import { ReactQueryDevtools } from "react-query/devtools";
const queryClient = new QueryClient();
</div>
+ <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Query Keys
- Query Keys @ react-query
- Effective React Query Keys @ Effective React Query Keys
string-only query keys
如果只使用 string 當作 query key 的話,實際上 react query 會把它轉成只有一個元素的 key,也就是說:
useQuery('todos' /*...*/); // queryKey === ['todos']
所有的 keys 都會被 hashed
react query 的 key 如果物件的話,因為會經過 hashed,所以物件中只要屬性都一樣,不管順序對 react-query 來說就是相同的 key;但如果是陣列的話,不同的順序就會是不同的 key。
Effective React Query Keys
在 TkDodo 的部落格中列出了幾點他個人喜歡的做法。
Colocate
不需要把所有的 query key 做集中式的管理,像是 src/utils/queryKeys.ts
,而是直接將這些 query key 管理在會用到的 query 旁,並擺放在以功能區分的資料夾中,像是:
https://tkdodo.eu/blog/effective-react-query-keys#colocate
- src
- features
- Profile
- index.tsx
- queries.ts
- Todos
- index.tsx
- queries.ts
總是使用 Array Keys
雖然 query keys 可以是 string,但實際上它依然會被轉成陣列,未來讓事情更單純,總是用 Array keys 吧。
Structure
根據廣泛程度從 generic 到 specific 來放在 array key 中,就很像 restful API 定義路由的方式一樣:
// https://tkdodo.eu/blog/effective-react-query-keys#structure
['todos', 'list', { filters: 'all' }][('todos', 'list', { filters: 'done' })][
('todos', 'detail', 1)
][('todos', 'detail', 2)];
如此將可以根據所需的程度 invalidate 對應的 query keys:
// https://tkdodo.eu/blog/effective-react-query-keys#structure
function useUpdateTitle() {
return useMutation(updateTitle, {
onSuccess: (newTodo) => {
// ex 1: invalidate 所有 todos 開始的 query keys
queryClient.invalidateQueries(['todos']);
// ex 2: invalidate 所有 todos list 開頭的 query keys
queryClient.invalidateQueries(['todos', 'list']);
},
});
}
使用 Query Key factories
每個不同功能都建立一個對應的 Query Key factory,像是這樣:
// https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const, // ['todos', 'list']
list: (filters: string) => [...todoKeys.lists(), { filters }] as const, // ['todos', 'list', { filters }]
details: () => [...todoKeys.all, 'detail'] as const, // ['todos', 'detail']
detail: (id: number) => [...todoKeys.details(), id] as const, // ['todos', 'detail', id]
};
如此,將可以非常方便的取用這些 query keys:
// https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories
// 移除所有看 todo feature 有關的 query 從 cache 中移除
queryClient.removeQueries(todoKeys.all);
// invalidate 所有 lists
queryClient.invalidateQueries(todoKeys.lists());
// fetch 單一個 todo
queryClient.useQuery(todoKeys.detail(id), () => fetchTodo(id));
Refetching
- 透過 refetch 可以確保過期的資料有從伺服器被更新
- 預設的情況下,當下列情形發生時,react query 會自動在背景 refetch 「"active" 且 "stale"」 的資料:
- 新的 query instance mounted 時,也就是該 query key 第一次被呼叫時
- 每一次 react 元件 mount 並執行 useQuery 時
- window refocused
- network reconnected
- refetchInterval 過期
- 若想改變預設值的話,可以透過下述參數設定在 global 或 query-specific 來進行調整
refetchOnMount
:預設是true
refetchOnWindowFocus
:預設是true
refetchOnReconnect
:預設是true
refetchInterval
:多久要 refetch 一次,以此達到 pulling 的 效果。可以帶入時間或函式,預設false
- 如果需要以 imperative 的方式來執行 refetch,可以使用
useQuery
會回傳的refetch
方法 - 建議只有在資料「很少會被改動」、「非重要即時的資料」才去調整讓 react query 不要自動 refetch,不然一般的情境下,為了避免增加 debug 難度或導致更多不必要的問題,都讓 react query 自己處理 refetch 即可。
透過 refetchInterval
的設定即可達到 pulling 的效果。
Mutations
- Mastering Mutations in React Query @ TkDodo's blog
- mutations
- use-mutations
mutation 指的是發送請求來 修改 server 上的資料(create/update/delete),也就是「帶有 side effect 的函式」。
在 react-query 中,可以使用 useMutation
來處理,其中 useQuery
和 useMutation
最大的差別在於,useQuery
是 declarative 的,而 useMutation
則是 imperative 的。
type Todo = {
id: string;
title: string;
};
// useMutation 中的 mutateFn 可以接受參數,這個參數的值會在呼叫 mutate 時被帶入
const mutation = useMutation<void, unknown, Todo>((newTodo) => {
return axios.post('/todos', newTodo);
});
// mutate 的參數內容,會帶入到 useMutation 的 mutateFn 中
mutation.mutate({ id: uuid(), title: 'foobar' });
- mutation 沒有 isFetching 的狀態,而是直接使用
isLoading
,因為 mutation 並沒有所謂的 cached data - 預設沒有 retry 的機制,但可以透過設定
retry
這個參數來達到
其中 UseMutationFunction
的型別會是:
useMutation<TData = unknown, TError = unknown, TVariables = void, TContext = unknown>
TData
指 mutate 後 API 回傳的資料型別TError
指 mutate 時若發生錯誤的資料型別TVariables
指 mutate 時帶入 mutateFn 的參數型別
mutate or mutateAsync
作者認為,一般的情況他都建議使用 mutate
,使用 mutate
的話,error 會被 react-query 有意的攔截下來(類似,mutateAsync.catch(noop)
),但如果是用 mutateAsync
的話,即使有用 onError
property,仍然需要自己用 try catch 來處理錯誤。
除非是因為需要併發多個 mutation、並等所有的結果都回來後才往後做事,或者是有多個 mutate 的結果要串連使用,否則會有 callback hell 的情況下才來考慮使用 mutateAsync
,否則多數時候都建議使用 mutate
就好。
有些 callback 並不會被呼叫
在 react-query 中可以在兩個地方帶入 callback。一個是在呼叫 useMutate
時,另一個則是在呼叫 mutate
時。
需要留意的是:
- useMutation 的 callback 會比 mutate 的 callback 早被呼叫到
- useMutation 的 callback 一定會被執行;不同地,一旦元件在 mutation 完成前就 unmount,則 mutate 的 callback 就不會被執行到
// https://tkdodo.eu/blog/mastering-mutations-in-react-query#mutate-or-mutateasync
// separate-concerns
const useUpdateTodo = () =>
useMutation(updateTodo, {
// ✅ always invalidate the todo list
onSuccess: () => {
queryClient.invalidateQueries(['todos', 'list']);
},
});
// in the component
const updateTodo = useUpdateTodo();
updateTodo.mutate(
{ title: 'newTitle' },
// ✅ only redirect if we're still on the detail page
// when the mutation finishes
{ onSuccess: () => history.push('/todos') },
);
除了執行的時間順序不同外,useMutation
中的 callback 如果回傳的是 Promise,則 react-query 會等整個 Promise resolved 後才算結束 loading,但如果是 mutate
的 callback 回傳 Promise 的話,則沒有這樣的效果。可參考 Inconsistency in onSuccess callback between being set in useMutation and mutate function 的說明。
結論:useMutation 的 callback 才能影響到這個 query 結束 isLoading (resolved) 的時間點,但 mutate 的 callback 不行。
作者建議根據需求不同分開撰寫 callback 中要做的事:
- 在
useMutation
的 callback 中執行一定到做的邏輯(例如,query invalidation) - 在
mutate
的 callback 中做和 UI 有關的操作,像是 redirect 或顯示錯誤訊息等等。這種如果元件在 mutation 完成前就已經 unmount 的話,不執行也是合理的邏輯。
useMutation
和 mutate
裡的 callback 並不是覆蓋(override)的關係,而是兩個都會被呼叫,且會先執行 useMutation 的 callback 後,才執行 mutate 中的 callback。
awaited Promises
在 mutation callback 中如果回傳的是 Promise,則會被視為是 mutation 的一部分,當這個 Promise 結束後,整個 mutation 才算是完成。
因為 invalidateQueries
回傳的是 Promise,所以如果希望整個 mutation 是等到 invalidateQueries 執行完後才會完成,則可以 return
它:
// https://tkdodo.eu/blog/mastering-mutations-in-react-query#awaited-promises
// awaited-promises:
{
// 🎉 will wait for query invalidation to finish
onSuccess: () => {
return queryClient.invalidateQueries(['posts', id, 'comments']);
};
}
{
// 🚀 fire and forget - will not wait
onSuccess: () => {
queryClient.invalidateQueries(['posts', id, 'comments']);
};
}
如何判斷正在 mutating
- Status of useMutation not shared across usages/components with custom hook (like useQuery does) @ GitHub issues
- useIsMutating @ official
雖然 useMutation
會回傳 isLoading
的狀態,但 useMutation 的 isLoading 和 useQuery 的 isLoading 有些微不同。useQUery 的 isLoading 只要 query key 一樣,都可以在不同元件間拿到該 query isLoading 的狀態;但 useMutation 如果想在不同元件間拿到 mutation isLoading 的狀態,需要使用 useIsMutating
的方式。可以參考 Status of useMutation not shared across usages/components with custom hook (like useQuery does) 的說明。
- 每個 useMutation 間的 state 是獨立的,保有各自的狀態
- 每個 useQuery 間的 state 則是共用的,可以透過 queryKey 找出需要用的
// Example
const SOME_MUTATION_KEY = 'mutationKey';
function ComponentA() {
const { mutate } = useMutation(mutationFn, {
mutationKey: SOME_MUTATION_KEY
});
const handleClick = () => {
mutate(); // invoke the mutation
}
return (/* ... */)
}
// get the mutation state from another component
function App() {
// the useIsMutating will return the number that is mutating
const numOfMutating = useIsMutating(SOME_MUTATION_KEY);
const isMutating = numOfMutating > 0;
return (
<button disabled={isMutating}>Save</button>
)
}
Mutation 後 cache 的處理方式
觸發重拉一次資料:invalidateQueries
觸發 data refetching(invalidation),可以使用 queryClient.invalidateQueries(<key>)
,它會做到的是:
- 當下
active
的 queries 會立即被 refetch,其餘的 queries 則會變成stale
待下次使用到時再去拉最新的
// https://tkdodo.eu/blog/mastering-mutations-in-react-query
// invalidation-from-mutation
const useAddComment = (id) => {
const queryClient = useQueryClient();
return useMutation((newComment) => axios.post(`/posts/${id}/comments`, newComment), {
onSuccess: () => {
// ✅ refetch the comments list for our blog post
queryClient.invalidateQueries(['posts', id, 'comments']);
},
});
};
多數情境下,作者更偏好使用 invalidateQueries
(即,從新拉一次資料)會比 setQueryData
(即,從 mutate 後 BE 回傳的資料來更新畫面),因為後者出來 BE 可能會需要更多重複的程式碼外,也更容易因為時間差而沒辦法取得實際最新的資料(參考)。
使用 mutate 後 API 回傳的資料: setQueryData
用 server 回傳的資料來更新 react query 中的 cached data,可以使用 queryClient.setQueryData(<key>, <value>)
。
透過 setQueryData
來將資料放到 cache 中,就很類似這些資料是直接從 BE 來的,所以所有使用到這個 query key 的元件元件也都會 re-render:
// https://tkdodo.eu/blog/mastering-mutations-in-react-query
// update-from-mutation-response
const useUpdateTitle = (id) => {
const queryClient = useQueryClient();
return useMutation((newTitle) => axios.patch(`/posts/${id}`, { title: newTitle }), {
// 💡 response of the mutation is passed to onSuccess
onSuccess: (newPost) => {
// ✅ update detail view directly
queryClient.setQueryData(['posts', id], newPost);
},
});
};
需要特別留意的是,setQueryData
會觸發相對應 query key 的 useQuery 中的 onSuccess
callback 執行。
和 setQueryData
有一個相對的是 removeQueries
,setQueryData
會觸發 onSuccess
callback,但 removeQueries
不會。
Optimistic Updates And Data Rollback
Optimistic Updates @ react-query
Optimistic Update 會假設 API 請求一定會成功,所以在 API 回傳成功前就先修改 client 端的資料,常見的像是「按讚」功能,可以直接參考官方文件的說明。
使用 Optimistic Update 的好處是會讓使用者覺得 UI 的反應很及時;但因為它是假設 API 請求一定會成功,所以壞處的,一旦這個請求失敗了,需要做相較更複雜的 rollback,使用者對 UI 的感受也會不太舒服。
作者認為, optimistic update 有點被太過濫用,optimistic updates 比較適合用在 API 很少會失敗(或失敗也沒關係)的情境,且立即的回應是必要的情境下(例如,toggle button),因為會 rollback 的 UI 常給人不好的使用者體驗。
type Todo = {
id: number;
title: string;
}
const queryClient = useQueryClient();
const { mutate: patchTodo } = useMutation<
Todo,
unknown,
Todo,
{ previousTodo: Todo; newTodo: Todo }
>(/* mutateFn */, {
onMutate: async (newTodo) => {
// 1-1 cancel 所有正在進行中的 queries,避免 server 回傳的資料影響到 optimistic update
await queryClient.cancelQueries(['todos', newTodo.id]);
// 1-2 將舊的 user 資料做一個 snapshot,可以在失敗時作為 rollback 使用
const previousTodo = queryClient.getQueryData<Todo>([
'todos',
newTodo.id,
]);
// 1-3 optimistic update
queryClient.setQueryData(['todos', newTodo.id], newTodo);
// 1-4 回傳 rollback 會用到的資料到 context 中,可以在 onError 中被取得
return { previousTodo, newTodo };
},
onSuccess: (data) => {
// ...
},
onError: (err, data, context) => {
// 2-1 rollback,失敗的話把 snapshot 的結果還回去
queryClient.setQueryData(
['todos', context.newTodo.id],
context.previousTodo,
);
},
onSettled: (data, error, variables, context) => {
// 3-1 重新 fetch 一次資料,確保最終 UI 呈現的資料和 server 上是一致的
queryClient.invalidateQueries(['todos', context.newTodo.id]);
},
});
Initial Query Data
- Initial Query Data @ react-query
- Placeholder Query Data @ react-query
- Placeholder and Initial Data in React Query
- Adding Data to the Cache @ Udemy > React Query: Server State Management in React
下表資料來自:React Query: Server State Management in React @ Udemy
where to use | data from | added to cache | |
---|---|---|---|
placeholderData | option to useQuery | client | no |
initialData | option to useQuery | client | yes |
prefetchQuery | method to queryClient | server | yes |
setQueryData | method to queryClient | client | yes |
placeholder data
當在使用 useQuery 時,「不需要」讓該初始資料進到 react-query 的 cache,此時 query state 會直接變成 success(但仍可以透過 isPlaceholderData
來判斷它是不是 placeholder data),同時 react-query 會在背景 fetch 最新的資料:
const placeholderData = [];
const { data } = useQuery('todos', queryFn, {
placeholderData: placeholderData,
});
另一中作法是使用 object 的 default value,這種作法的 query state 就不會直接進到 success
,而是會走一般的生命週期:
const placeholderData = [];
const { data = placeholderData } = useQuery(/* ... */);
initialData
當在使用 useQuery 時,希望該初始值的資料能進到 react-query 的 cache,此時 query state 一樣會直接變成 success,至於 react-query 什麼時候會再拉最新的資料,則是會依照 staleTime
的設定,因此不一定會去拉最新的資料:
const { data } = useQuery(<queryKey>, <queryFn>, {
initialData: /* put initial data here */,
})
不論是 placeholderData
或 initialData
,都不會讓該 query 進到 "loading" state,而是會直接進入 "success" state,它們兩者最大的差別在於資料會不會進到 query cache 中,也就是說,placeholderData 是作用在 observer level,而 initialData 則是作用在 cache level([cache level and observer level](#cache level and observer level))。如果想進一步了解這兩者的差異,可以參考 Placeholder and Initial Data in React Query @ TkDodo's blog
prefetchQuery
提前 query 需要用到的資料:
import { useQueryClient } from 'react-query';
export function usePrefetchTreatments(): void {
const queryClient = useQueryClient();
queryClient.prefetchQuery(queryKeys.treatments, getTreatments, {
// 見下方說明
staleTime: Infinity,
});
}
使用 prefetch 能確保使用者在進到某個頁面前就已經可以拿 cache 中的資料「出來顯示」,但並不表示 react query 不會再去 fetch 一次資料,如果不希望再很短的時間內就再重複 fetch 資料,可以透過 staleTime
來設定。
這裡在 prefetchQuery
中用了 staleTime: Infinity
,意思是「這個 query」不會過期,但不是指「這個 query key 對應到的所有 query」都不會過期,也就是說,如果有在其他地方呼叫 useQuery(queryKeys.treatments, getTreatments)
,因為 stale time 不是在這個 query 上,所以還是會去 fetch 資料。
useQuery
和 prefetchQuery
有各自 staleTime 和 cacheTime 的設定,即使他們有相同的 query key,但其設定仍是需要各自獨立設定的。
Data Transformations
React Query Data Transformations @ TkDodo's blog
在 React Query 中提供 select
這個屬性可以把收到的資料進行轉換,並有幾個重點:
- select 只有在資料存在的時候才會被呼叫,因此不用擔心資料是
undefined
的問題 - select 如果是
undefined
而不是 function,則不會對 data 做任何事 - 具有 memorize 的功能,只有在下述情況
select
才會再次被執行(#18)data
改變,也就是select
的參數改變時select
function 的參照改變時,所以可以使用useCallback
或將此 function 放到元件外以避免不必要的重複執行 dd
// https://tkdodo.eu/blog/react-query-data-transformations
// select-memoization:避免不要的執行 select function
// 方法一:將 transform function 放到元件外
const transformTodoNames = (data: Todos) => data.map((todo) => todo.name.toUpperCase());
export const useTodosQuery = () =>
useQuery(['todos'], fetchTodos, {
// ✅ uses a stable function reference
select: transformTodoNames, // 如果是 undefined 則 select 不會執行
});
// 方法二:使用 useCallback
export const useTodosQuery = () =>
useQuery(['todos'], fetchTodos, {
// ✅ memoizes with useCallback
select: React.useCallback((data: Todos) => data.map((todo) => todo.name.toUpperCase()), []),
});
透過 select
這個屬性,就可以做到類似於 redux 的 selectors:
// https://tkdodo.eu/blog/react-query-data-transformations
// select-partial-subscriptions
// pass the select as parameter
const someFunc = <T = SomeDataType>(select?: (data: SomeDataType) => T) =>
useQuery(['someQuery', fetcher, { select })
// 直接把 select 當成參數帶入 hooks
export const useTodosQuery = (select) => useQuery(['todos'], fetchTodos, { select });
使用時就可以直接把需要的資料 select 出來:
// 直接使用 hook 把需要的資料取出
const useTodosCount = () => useTodosQuery((data) => data.length);
const useTodo = (id) => useTodosQuery((data) => data.find((todo) => todo.id === id));
Dependent Queries
Dependent Queries @ react-query
dependent queries 指的是「在滿足特定條件時才會執行的 query」,例如,userId 存在才去 fetch orders。
在 react query 中要做到 dependent queries 相當簡單,只需要使用 query options 中的 enabled
即可:
- 當
enabled
是false
時,就不會執行該 query
const { data } = useQuery(['orders', userId], getOrdersByUser, {
// 當 userId 存在時,該 query 才會被執行
enabled: !!userId,
});
如何處理 queryFn 需要的參數是 optional 的
- Dependent Queries: type of 'enabled' parameters @ Github Discussion
- Type safety with the enabled option @ Tkdodo
如果希望當 id
不存在是就不要執行 query,Tkdodo 建議可以這樣寫:
// https://tkdodo.eu/blog/react-query-and-type-script#type-safety-with-the-enabled-option
function fetchGroup(id: number | undefined): Promise<Group> {
if (typeof id === 'undefined') {
return Promise.reject(new Error('Invalid id'));
}
return fetch(`/example/${id}`)
.then((response) => response.json())
.then((data) => data as Todo);
}
function useGroup(id: number | undefined) {
return useQuery({
queryKey: ['group', id],
// 邏輯上,enabled 是 false 是這個 queryFn 就不會被執行
// 但因為 id 的型別是 number | undefined
// 所以 fetchGroup 中 id 這個參數的型別也需要是 number | undefined
queryFn: () => fetchGroup(id),
enabled: Boolean(id),
});
}
Error Handling
React Query Error Handling @ TkDodo's blog
若是使用 fetch API,則需要參考官網的設定方式,Usage with fetch
and other clients that do not throw by default @ react-query
留意 onError callback 被呼叫的時機
雖然 useQuery
有提供 onError
callback,但要留意的是,這個 onError
callback 會在每一個 Observer 都被執行,也就是說,如果某個 useQuery 同時被多個 observer 觀察時,即使只有一個 network request failed,但所有的 observer 都會收到這個通知。
const { data, isLoading, isError, error } = useQuery(
['comments', post.id],
() => fetchComments(post.id),
// 使用 useQuery 中的 onError callback
{
onError: (error) => toast.error(`Something went wrong: ${error.message}`),
},
);
一個 query 有多個 observer 的情況可能會發生在同一個頁面中,重複使用了相同的 query 時,舉例來說,拉取使用者資料的 query 同時在 sidebar 和 navbar 都被使用,這時候的 observer 就會是兩個。
透過 react-query 的開發者工具,可以從最前方的數字看到某個 query 被多少個 observer 所觀察,從下圖可以看到:
-
["comments", 1]
這個 query 有 2 個 observer -
["posts", 1]
這個 query 有 1 個 observer
因此,當我們使用 useQuery
中的 onError
callback 時,要留意它有可能會一次跳出多個錯誤訊息。如下圖可以看到,因為 ["comments", n]
同時有兩個 observer,所以當錯誤發生時,會一次跳出兩個 error message:
global callback:確保一個 network request 只執行一次 callback
如果在某些時候,需要避免上述這種同時跳出多個錯誤訊息的情況,可以使用 global callback,這時候同一個 network request 失敗時,就只會顯示一次錯誤訊息。
要設定 global callback 需要在建立 QueryClient 時,並使用 queryCache
的設定:
// background-error-toasts
// https://tkdodo.eu/blog/react-query-error-handling#putting-it-all-together
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
// 🎉 only show error toasts if we already have data in the cache
// which indicates a failed background update
if (query.state.data !== undefined) {
toast.error(`Something went wrong: ${error.message}`);
}
},
}),
});
const App = () => <QueryClientProvider client={queryClient}>{/* ... */}</QueryClientProvider>;
放在 global callback 的話,就能確保一個 network request 最多觸發一次 callback,並且沒辦法被 default option 所覆蓋。
Query Cancellation
Query Cancellation @ React Query
預設來說,即使 query 提早被 unmount 或沒有被使用(即,不是 active
),它所發出的 Promise 都還是會持續直到 resolved 或 rejected,而**不會被 cancel**,這麼做是確保使用者下次使用到該 query 時就已經有最新的資料在 cache 中。
然而有些情況下,可能會真的希望取消這個發出去的請求,例如等待時間過長,或者使用者按下取消。在瀏覽器支援的情況下,React-query 會提供每一個 query function AbortSignal
的實例(instance),透過把這個 signal
傳入 fetchFn 中,即可在需要的時候把該請求取消掉。被取消的 query 它的資料會回到前一次的狀態。
// https://react-query.tanstack.com/guides/query-cancellation#using-fetch
export const useTodoQuery = (id: number | undefined) =>
useQuery<Todo>({
queryKey: ['todo', id],
queryFn: ({ signal }) => getTodo(id, signal),
enabled: !!id,
});
// https://react-query.tanstack.com/guides/query-cancellation#using-fetch
export const getTodo = async (
id: number | undefined,
signal?: AbortSignal,
): Promise<Todo> => {
if (typeof id === 'undefined') {
return Promise.reject(new Error('id is undefined'));
}
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
signal,
});
const data = (await response.json()) as Todo;
return data;
};
如果有把 Signal 傳入 fetchFn 的話,一旦
- 這個 query 在還沒拿到資料前變成 inactive
- 或又透過
refetch
發出新的請求時
這個 signal 就會被取消(aborted),從 Network 也可以看到發出去的請求變成 "canceled":
相反的,如果沒有帶入 Signal 的話,這些發出去的請求會持續等到 API 的 response 回來後保存在 cache 中:
Manual Cancel
和 useQuery 中有 refetch
可以手動發送請求一樣,如果有需要手動取消某個請求,可以使用 queryClient.cancelQueries
export default function TodoCard({ todoId }: TodoCardProps) {
const queryClient = useQueryClient();
// ...
function handleCancel() {
queryClient
.cancelQueries({ queryKey: ['todos', todoId] })
.catch((error) => console.error(error));
}
return /*...*/;
}
Prefetching
prefetching @ react-query
prefetch 的功能是先把使用者可能會用到的資料保存一份在 cache 中,讓使用者進到該頁面時不必再等待 loading 的狀態。
預設的情況下,還是會觸發 refetching 已經在 prefetching 時 cache 過的資料,需要的話可以修改 useQuery
中的 staleTime
參數。
// https://www.udemy.com/course/learn-react-query/learn/lecture/26581580
import { useQuery, useQueryClient } from 'react-query';
async function getAppointments(year: string, month: string): Promise<AppointmentDateMap> {
const { data } = await axiosInstance.get(`/appointments/${year}/${month}`);
return data;
}
export function usePrefetchAppointments(year: string, month: string): void {
const queryClient = useQueryClient();
useEffect(() => {
queryClient.prefetchQuery([queryKeys.appointments, year, month], () =>
getAppointments(year, month),
);
}, [year, month]);
}
Infinite Queries
透過 react query 來做 infinite query 的關鍵是使用 useInfiniteQuery
提供的:
- 在 fetchFn 中可以透過參數
{ pageParam }
來取得當前的 pageParam 並發送 API 請求 getNextPageParam
:透過回傳值來「設定下一頁的 pageParam」,如果沒有下一頁則回傳undefined
fetchNextPage
:用在「需要載入更多資料」時,此方法會以新的 pageParam 來呼叫 fetchFnhasNextPage
:是否還有下一頁的資料(根據getNextPageParam
的回傳值是否為undefined
決定),用來決定是否還有需要呼叫下一次的 fetchNextPage- 在
data.pages
得到 API 回傳的結果;需要的話,在data.pageParams
可以取得每次使用的 page param
const {
fetchNextPage,
// fetchPreviousPage,
hasNextPage,
// hasPreviousPage,
isFetchingNextPage,
// isFetchingPreviousPage,
// 和 useQuery 回傳相同屬性名稱,但內容略有不同的物件
data,
} = useInfiniteQuery(
queryKey,
// queryFn: (context: QueryFunctionContext) => Promise<TData>
({ pageParam = 1 }) => fetchPage(pageParam),
// options
{
...options,
// 回傳 undefined 表示沒有下(上)一頁
// 否則回傳用來可以作為下(上)一頁的值(例如,page number)
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor || undefined,
// getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
},
);
const {
pages, // TData[],每次 API 回傳的資料
pageParams, // unknown[],每次取得資料頁面所用的參數(page params)
} = data;
流程
-
最一開始因為沒有 cached data,useInfiniteQuery 回傳的
data
會是undefined
-
接著,在 useInfiniteQuery 中的 queryFn 中會帶入第一次 fetch 時用的 pageParam(提供預設值,例如
1
),並執行 fetch 的動作,發送的請求可能類似https://foo.bar/?page=1
,接著在data.pages
中會得到回傳的結果// queryFn: (context: QueryFunctionContext) => Promise<TData>
({ pageParam = 1 }) => fetchPage(pageParam); -
當使用者滾動到頁面底部時,執行
getNextPageParam
這個方法,在這個方法中,會回傳 fetch 下一頁需要用到的 pageParam(例如2
)// 回傳下一頁要用到的 pageParam
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor; -
如果
getNextPageParam
不是回傳undefined
,則hasNextPage
會是true
。此時可以透過呼叫fetchNextPage
讓 react-query 以新的 pageParam 再次執行 queryFn。此時發送的請求可能類似https://foo.bar/?page=2
,API 回傳的結果同樣會保存在data.pages
中,在data.pageParams
中則會保存這次 fetch 時所使用的 pageParam// 以 react-infinite-scroller 為例
<InfiniteScroll loadMore={fetchNextPage} hasMore={hasNextPage}>
{data.pages.map(/* ... */)}
</InfiniteScroll> -
直到
getNextPageParam
回傳的是undefined
,hasNextPage
會是false
。此時我們將不再呼叫fetchNextPage
這個方法,於是 queryFn 不會再被呼叫
API 說明
使用 useInfiniteQuery
時有些地方和 useQuery
不太一樣:
data
物件中現在會包含 infinite query datadata.pages
陣列中會包含「每次 API 回傳的資料(fetched pages)」data.pageParams
陣列中會包含用來「每次取得資料頁面所用的參數(page params)」,也就是每次getNextPageParams
函式中回傳的值
另外,也多了一些額外可以使用的屬性:
- 如果
getNextPageParam
回傳的是undefined
以外的值,則haxNextPage
會是true
- 除了可以使用原本的
isFetching
之外,也可以使用isFetchingNextPage
或isFetchingPreviousPage
如果使用者都是透過向下滾動捲軸才會獲得資料的話,可以使用 next 相關的方法即可,但如果使用者有可能從中間後後面向前滾動捲軸時才獲得資料的話,則需要搭配使用 previous 相關的方法(例如,getPreviousPageParam
、hasPreviousPage
、fetchPreviousPage
等)。
可以搭配 react-infinite-scroller 使用。
Code Snippet
// https://www.udemy.com/course/learn-react-query/learn/lecture/26581458
import InfiniteScroll from 'react-infinite-scroller';
import { Species } from './Species';
import { useInfiniteQuery } from 'react-query';
const initialUrl = 'https://swapi.dev/api/species/';
const fetchUrl = async (pageParam) => {
const endpoint = new URL(initialUrl);
endpoint.search = new URLSearchParams({
page: pageParam,
});
const response = await fetch(endpoint);
return response.json();
};
export function InfiniteSpecies() {
const { data, isLoading, isError, error, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery(
'sw-species',
({ pageParam = 1 }) => {
return fetchUrl(pageParam);
},
{
getNextPageParam: (lastPage, allPages) => (lastPage.next ? allPages.length + 1 : undefined),
},
);
if (isLoading) {
return <div className="loading">Loading...</div>;
}
if (isError) {
return <div>Oops! Something goes wrong ({error.message})</div>;
}
return (
<>
{isFetchingNextPage && <div className="loading">Loading...</div>}
<InfiniteScroll hasMore={hasNextPage} loadMore={fetchNextPage}>
{data.pages.map((pageData) =>
pageData.results.map((species) => (
<Species
key={species.name}
name={species.name}
language={species.language}
averageLifespan={species.average_life_span}
/>
)),
)}
</InfiniteScroll>
</>
);
}
Paginated Queries: keep previous data
paginated queries @ react-query
預設的情況下,在做 pagination 的功能時,因為 query 的 key 是不同的,所以 useQuery
的狀態會在 success 和 isLoading 間不停切換,這會導致使用者每次只要一換頁就會看到 loading 的頁面(很煩):
要避免這樣的情況,可以在 useQuery
中多加上 keepPreviousData: true
的參數:
const { data, isLoading, isError, error } = useQuery(
['posts', currentPage],
() => fetchPosts(currentPage),
{
keepPreviousData: true,
},
);
加上這個參數後,即使 useQuery
中的 key 改變了,它會等到取得 API 回傳最新的資料後,才改變 data,讓使用者感覺變得比較流暢:
但這有可能衍生的另一個問題是,如果使用者的網速很慢導致點擊按鈕後 data 沒有立即的改變時,使用者可能會誤以為自己沒有點到按鈕或覺得 App 壞掉。
透過將 keepPreviousData
設成 true
,當 query 的 key 改變時,雖然同樣會發出新的請求,但:
useQuery
的data
會先回傳前一次成功取得的資料(last successful fetch),而不是undefined
,直到收到 API response 後,才回傳最新一次取得的資料。useQuery
的status
會保持在success
(因為是拿前一次 success response 的資料),而不會於loading
和success
間切換
在做 paginated queries 時,除了可以使用這裡提到的 keepPreviousData
之外,也可以搭配 prefetching 使用,讓下一頁的資料提早先被取得。
TypeScript
- TypeScript @ TanStack Query v4
- React Query and TypeScript @ TkDodo's blog
Type Inference:記得定義 queryFn 中 API 回傳的資料型別
大部分的情況下,useQuery
都能自動推導出正確的型別,但由於一般的 fetch API 預設都會將 response 的型別設為 any
,所以建議在 queryFn 的地方要定義 API response 的型別。例如:
export const getTodoList = () => {
return fetch('https://jsonplaceholder.typicode.com/todos/')
.then<Todo[]>(response => response.json())
}
或
export const getTodoList = () => {
return axios.get<Todo[]>('https://jsonplaceholder.typicode.com/todos/')
.then(response => response.data)
}
Type Narrowing:使用 isSuccess 來過濾 undefined 的情況
React Query 會使用 discriminated union type 來做 Type Narrowing,因此需要的時候,搭配 isSuccess
使用,可以確保資料不是 undefined
:
// data: Todo[] | undefined
const { data, isSuccess } = useQuery(['todo-list'], getTodoList);
if (isSuccess) {
// data: Todo[]
console.log(data);
}
Typing the error field
預設 try...catch 裡,catch 的 error 型別會是 unknown
(useUnknownInCatchVariables 預設會開啟),因此比較好的方式是在 runtime 時用 instanceof
來判斷:
const { data, isSuccess, error } = useQuery(['todo-list'], getTodoList);
if (error instanceof Error) {
// error: Error
error;
}
Testing React Query
- Testing React Query @ TkDodo's blog
- Testing @ React Query
- custom render @ Testing Library
在進行測試前,需要留意一下幾點:
- 將有用到 react-query 的元件包在
<QueryClientProvider />
中才能進行測試 - 在測試環境中提供 queryClient 的 defaultOptions 時,要留意 singleton 的情況,也就是不要參照到相同的物件,而是要建立一個新的
當測試可能會導致 react-query 回傳錯誤的 query 時,留意有沒有需要
-
透過setLogger()
關閉 error 的錯誤,避免在執行測試時出現不影響測試結果的錯誤訊息 - 也許需要關閉 retry 的預設值,避免測試 timeout 的情況
setLogger()
是 v3 的 API,在 v4 已經被移除,可以直接透過 defaultOptions
把 logger 的設定給進去。
renderWithQueryClient
Day28 測試依賴外層 Context Provider 的 React 元件:客製化 render 函式 @ pjchender
建立一個客製化的 render function,使用此 render function 的 component 都會被包在 QueryClientProvider 中:
import { ThemeProvider } from '@mui/material/styles';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, RenderOptions } from '@testing-library/react';
import * as React from 'react';
import { theme } from '@/theme';
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
logger: {
log: console.log,
warn: console.warn,
// eslint-disable-next-line @typescript-eslint/no-empty-function
error: () => {},
},
});
// https://tkdodo.eu/blog/testing-react-query#for-custom-hooks
const createWrapper = ({ queryClient }: { queryClient: QueryClient }) => {
return function QueryClientWrapper({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ThemeProvider>
);
};
};
// https://github.com/TkDodo/testing-react-query/blob/main/src/tests/utils.tsx
export function renderWithClient(ui: React.ReactElement, options?: RenderOptions) {
const testQueryClient = createTestQueryClient();
const { rerender, ...result } = render(ui, {
wrapper: createWrapper({ queryClient: testQueryClient }),
...options,
});
return {
...result,
rerender: (rerenderUi: React.ReactElement) =>
rerender(<QueryClientProvider client={testQueryClient}>{rerenderUi}</QueryClientProvider>),
};
}
React Query v3
// https://www.udemy.com/course/learn-react-query/learn/lecture/26581740
// test-utils.tsx
/* eslint-disable no-console */
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider, setLogger } from 'react-query';
import { generateDefaultQueryClientOptions } from '../react-query/queryClient';
setLogger({
log: console.log,
warn: console.warn,
// NOTE: 如果覺得 react query 內部的錯誤訊息太惱人,可以讓它靜音(不影響測試)
error: () => {},
});
// from https://tkdodo.eu/blog/testing-react-query#for-custom-hooks
export const createQueryClientWrapper = (client?: QueryClient) => {
const defaultOptions = generateDefaultQueryClientOptions();
// NOTE: 如果 retry 不設成 0 的話,會導致測試時 findByXXX timeout 出錯
defaultOptions.queries.retry = 0;
const queryClient = client ?? new QueryClient({ defaultOptions });
return function QueryClientWrapper({ children }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
};
const renderWithQueryClient = (
ui: ReactElement,
client?: QueryClient,
options?: Omit<RenderOptions, 'wrapper'>,
) => render(ui, { wrapper: createQueryClientWrapper(client), ...options });
export { renderWithQueryClient as render };
testing custom hook that use react-query
// https://www.udemy.com/course/learn-react-query/learn/lecture/26581764
import { act, renderHook } from '@testing-library/react-hooks';
import { createQueryClientWrapper } from '../../../test-utils';
import { useStaff } from '../hooks/useStaff';
test('filter staff', async () => {
// render the custom hook
const { result, waitFor } = renderHook(() => useStaff(), {
// 這裡因為 react-query 需要被包在 <QueryClientProvider /> 才能使用
wrapper: createQueryClientWrapper(),
});
/// assert the all staff length should larger thant 0
await waitFor(() => result.current.staff.length > 0);
// can ge the number of staffs after waitFor
const allStaffLength = result.current.staff.length;
// wrap method that will change the state in the hook in the act function
act(() => {
// filter by treatment
result.current.setFilter('scrub');
});
// assert the all staff length is greater than the filtered staff length
await waitFor(() => allStaffLength > result.current.staff.length);
});
API
useQuery
useQuery @ react-query > API Reference
const {
data
} = useQuery(queryKey, queryFn?, {
onError, // (error: TError) => void,當錯誤發生時要執行的事
})
- 預設的情況下,query 回來的資料會立即變成 stale(
staleTime = 0
),也就是說,我們假設每次都應該要向 server 請求新的資料回來,以避免不熟 react-query 的開發者因為資料沒有更新而苦惱許久。需要的話,則可以透過staleTime
這個參數來修改。 - stale query 會在下述時間點自動在背景 refetch:
- 新的 query instance mount 時
- window refocused 時(
refetchOnWindowFocus
) - network reconnected 時(
refetchOnReconnect
) - 有設定 refetch internal 時
- Callbacks
onMutate
callback 回傳的內容,可以在onSuccess
,onError
或onSettled
的參數 "context
" 中被取得onSettled
:不論最後是該 Promise 是成功或失敗都會進到這個 hook
QueryClient
const queryClient = new QueryClient(
// QueryClientConfig
{
// The query cache this client is connected to.
// https://react-query.tanstack.com/reference/QueryCache
queryCache: {
onError, // global callback
onSuccess, // global callback
},
// The mutation cache this client is connected to.
// https://react-query.tanstack.com/reference/MutationCache
mutationCache: {
onError, // global callback
onSuccess, // global callback
},
// Define defaults for all queries and mutations using this queryClient
// 和 global callback 不同,這裡的 defaultOptions 可以被個別 query 或 mutation 的 options 取代
defaultOptions: {
queries: {
// useQuery 中可以套用的 options
},
mutations: {
// useMutation 中可以套用的 options
},
},
},
);
如果想知道在不改變 options 時 useQuery
的預設值是什麼,需要到 useQuery
的 API 文件 才有寫。
Example
Custom Hooks
Custom Hooks @ react-query > Examples
可以把透過 useQuery 拉取資料的程式封裝在 custom hook 中,透過這個 custom hook:
- 可以共用拉取資料的程式邏輯
- 將 display component 和 data component 拆成不同 layer
import type { Todo } from '@/types';
import { axiosInstance } from '@/utils/axiosInstance';
import { useQuery } from 'react-query';
async function getTodos(): Promise<Todo[]> {
const { data } = await axiosInstance.get<Todo[]>('/Todos');
return data;
}
export function useTodos(): Todo[] {
const fallbackData = [];
const { data = fallbackData } = useQuery('todos', getTodos);
return data;
}
Global React Question Options
要看 QueryClient
有哪些參數可以使用,最簡單的方式是可以直接看套件 TS 的型別定義:
export interface QueryClientConfig {
queryCache?: QueryCache;
mutationCache?: MutationCache;
defaultOptions?: DefaultOptions;
}
export interface DefaultOptions<TError = unknown> {
queries?: QueryObserverOptions<unknown, TError>;
mutations?: MutationObserverOptions<unknown, TError, unknown, unknown>;
}
使用上:
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10 * 60 * 1000,
cacheTime: 15 * 60 * 1000,
onError: queryErrorHandler,
retry: 1,
},
mutations: {
onError: queryErrorHandler,
},
},
});
Reference
- Mastering Mutations in React Query @ TkDodo's blog
- React Query: Server State Management in React @ Udemy