跳至主要内容

[npm] react-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,包含:loadingerrorsuccess
  • fetchStatus:指的是 queryFn 的狀態,用來判斷「queryFn 是否有在執行」,其狀態型別為 FetchStatus,包含:fetchingpauseidle

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 後被清除:

inactive query

query status

useQuery 的 status 一共包含:

  • idle
  • error
  • loading
  • success

isFetching vs. isLoading

  • isFetching 是送出 API 請求但該請求還沒被 resolved
  • isLoading 指的是除了正在 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 levelobserver level

Global:以 cache level 來說,每一個 query key 都會對應到一組 cache entry,這是「一對一的關係」,透過 query key 就可以讓我們在整個 App 中讀取到這些 query data。useQuery 中的一些設定就是直接作用在 cache level 的,例如 stateTimecacheTime,這些設定就是會跟著這個 cache entry(全域都一樣)。

Local:另一個是 observer level,每當使用一次 useQuery 就會建立一個 observer,每一個 observer 同樣會根據 query key 對應到一組 cache entry,但這是「多對一的關係」,也就是說,可以有多個 observer 都在監控同一組 cache entry(即,使用 useQuery 且帶入相同的 query key),每當資料有改變的時候,就會觸發元件重新轉譯(re-render)。useQuery 中的一些設定則是作用在 observer level,例如,selectkeepPrevious,這些設定會隨著不同的 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

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

mutation 指的是發送請求來修改 server 上的資料(create/update/delete),也就是「帶有 side effect 的函式」。

在 react-query 中,可以使用 useMutation 來處理,其中 useQueryuseMutation 最大的差別在於,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 時。

需要留意的是:

  1. useMutation 的 callback 會比 mutate 的 callback 早被呼叫到
  2. 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 的話,不執行也是合理的邏輯。
警告

useMutationmutate 裡的 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

雖然 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 有一個相對的是 removeQueriessetQueryData 會觸發 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

下表資料來自:React Query: Server State Management in React @ Udemy

where to usedata fromadded to cache
placeholderDataoption to useQueryclientno
initialDataoption to useQueryclientyes
prefetchQuerymethod to queryClientserveryes
setQueryDatamethod to queryClientclientyes

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 */,
})
提示

不論是 placeholderDatainitialData,都不會讓該 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 資料。

提示

useQueryprefetchQuery 有各自 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
// selec-memoziations:避免不要的執行 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 即可:

  • enabledfalse 時,就不會執行該 query
const { data } = useQuery(['orders', userId], getOrdersByUser, {
// 當 userId 存在時,該 query 才會被執行
enabled: !!userId,
});

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

Screen Shot 2022-03-26 at 12.25.39 AM

因此,當我們使用 useQuery 中的 onError callback 時,要留意它有可能會一次跳出多個錯誤訊息。如下圖可以看到,因為 ["comments", n] 同時有兩個 observer,所以當錯誤發生時,會一次跳出兩個 error message:

onError callback

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

預設來說,即使 query 提早被 unmount,它所發出的 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

const query = useQuery('todos', async ({ signal }) => {
// 把 signal 傳入單一個 fetch function 中
const todosResp = await fetch('/todos', { signal });
const todos = await todosResp.json();

// 把 signal 傳入單多個 fetch function 中
const todoDetailsPromises = todos.map(async ({ details }) => {
const resp = await fetch(details, { signal });
return resp.json();
});

const todoDetails = await Promise.all(todoDetailsPromises);
return todoDetails;
});

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 來呼叫 fetchFn
  • hasNextPage:是否還有下一頁的資料(根據 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;

流程

  1. 最一開始因為沒有 cached data,useInfiniteQuery 回傳的 data 會是 undefined

  2. 接著,在 useInfiniteQuery 中的 queryFn 中會帶入第一次 fetch 時用的 pageParam(提供預設值,例如 1),並執行 fetch 的動作,發送的請求可能類似 https://foo.bar/?page=1,接著在 data.pages 中會得到回傳的結果

    // queryFn: (context: QueryFunctionContext) => Promise<TData>
    ({ pageParam = 1 }) => fetchPage(pageParam);
  3. 當使用者滾動到頁面底部時,執行 getNextPageParam 這個方法,在這個方法中,會回傳 fetch 下一頁需要用到的 pageParam(例如 2

    // 回傳下一頁要用到的 pageParam
    getNextPageParam: (lastPage, allPages) => lastPage.nextCursor;
  4. 如果 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>
  5. 直到 getNextPageParam 回傳的是 undefinedhasNextPage 會是 false。此時我們將不再呼叫 fetchNextPage 這個方法,於是 queryFn 不會再被呼叫

useInfiniteQuery

API 說明

使用 useInfiniteQuery 時有些地方和 useQuery 不太一樣:

  • data 物件中現在會包含 infinite query data
  • data.pages 陣列中會包含「每次 API 回傳的資料(fetched pages)
  • data.pageParams 陣列中會包含用來「每次取得資料頁面所用的參數(page params)」,也就是每次 getNextPageParams 函式中回傳的值

另外,也多了一些額外可以使用的屬性:

  • 如果 getNextPageParam 回傳的是 undefined 以外的值,則 haxNextPage 會是 true
  • 除了可以使用原本的 isFetching 之外,也可以使用 isFetchingNextPageisFetchingPreviousPage

如果使用者都是透過向下滾動捲軸才會獲得資料的話,可以使用 next 相關的方法即可,但如果使用者有可能從中間後後面向前滾動捲軸時才獲得資料的話,則需要搭配使用 previous 相關的方法(例如,getPreviousPageParamhasPreviousPagefetchPreviousPage 等)。

提示

可以搭配 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 的頁面(很煩):

paginated queries

要避免這樣的情況,可以在 useQuery 中多加上 keepPreviousData: true 的參數:

const { data, isLoading, isError, error } = useQuery(
['posts', currentPage],
() => fetchPosts(currentPage),
{
keepPreviousData: true,
},
);

加上這個參數後,即使 useQuery 中的 key 改變了,它會等到取得 API 回傳最新的資料後,才改變 data,讓使用者感覺變得比較流暢:

keepPreviousData

警告

但這有可能衍生的另一個問題是,如果使用者的網速很慢導致點擊按鈕後 data 沒有立即的改變時,使用者可能會誤以為自己沒有點到按鈕或覺得 App 壞掉。

透過將 keepPreviousData 設成 true,當 query 的 key 改變時,雖然同樣會發出新的請求,但:

  • useQuerydata 會先回傳前一次成功取得的資料(last successful fetch),而不是 undefined,直到收到 API response 後,才回傳最新一次取得的資料。
  • useQuerystatus 會保持在 success(因為是拿前一次 success response 的資料),而不會於 loadingsuccess 間切換
提示

在做 paginated queries 時,除了可以使用這裡提到的 keepPreviousData 之外,也可以搭配 prefetching 使用,讓下一頁的資料提早先被取得。

TypeScript

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 會使用 discrimated 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 型別會是 unknownuseUnknownInCatchVariables 預設會開啟),因此比較好的方式是在 runtime 時用 instanceof 來判斷:

const { data, isSuccess, error } = useQuery(['todo-list'], getTodoList);

if (error instanceof Error) {
// error: Error
error
}

Testing React Query

在進行測試前,需要留意一下幾點:

  • 將有用到 react-query 的元件包在 <QueryClientProvider /> 中才能進行測試
  • 在測試環境中提供 queryClient 的 defaultOptions 時,要留意 singleton 的情況,也就是不要參照到相同的物件,而是要建立一個新的

當測試可能會導致 react-query 回傳錯誤的 query 時,留意有沒有需要

  • 透過 setLogger() 關閉 error 的錯誤,避免在執行測試時出現不影響測試結果的錯誤訊息
  • 也許需要關閉 retry 的預設值,避免測試 timeout 的情況

renderWithQueryClient

Day28 測試依賴外層 Context Provider 的 React 元件:客製化 render 函式 @ pjchender

建立一個客製化的 render function,使用此 render function 的 component 都會被包在 QueryClientProvider 中:

// 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 回來的資料會立即變成 stalestaleTime = 0),也就是說,我們假設每次都應該要向 server 請求新的資料回來,以避免不熟 react-query 的開發者因為資料沒有更新而苦惱許久。需要的話,則可以透過 staleTime 這個參數來修改。
  • stale query 會在下述時間點自動在背景 refetch:
    • 新的 query instance mount 時
    • window refocused 時(refetchOnWindowFocus
    • network reconnected 時(refetchOnReconnect
    • 有設定 refetch internal 時
  • Callbacks
    • onMutate callback 回傳的內容,可以在 onSuccess, onErroronSettled 的參數 "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
},
},
},
);

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

Giscus