跳至主要内容

Practical React Query

當 GraphQL,特別是 Apollo Client 在 2018 年左右變得流行時,大家都在討論它會不會完全取代 redux,甚至常常有人問 Redux 死了嗎?

我記得當時完全搞不懂大家在吵什麼。 為什麼一個抓資料的函式庫會取代全域狀態管理工具?這兩者到底有什麼關聯?

我原本以為像 Apollo 這種 GraphQL client 只是幫你抓資料, 就像 axios 處理 REST API 一樣, 你還是得自己想辦法把資料放到應用程式裡。

結果我大錯特錯。

Client State vs. Server State​

Apollo 不只是讓你描述想要哪些資料並抓下來, 它還內建了 快取 機制。 這代表你可以在多個元件裡用同一個 useQuery hook, 只會抓一次資料,之後都直接從快取拿。

這聽起來是不是很像我們(還有很多團隊)用 redux 做的事: 從 server 抓資料,然後讓整個 app 都能用。

所以我們一直把 server state 當成 client state 在處理。 但其實 server state(像是你抓的一串文章、要顯示的使用者細節...) 這些資料的擁有者是 server,不是你的 app。 我們只是「借來」顯示給使用者最新的狀態。

這讓我對資料的思考方式有了轉變。 如果我們能善用快取來顯示不是自己擁有的資料, 那其實真正需要全域 client state 的情境就少很多。 這也讓我理解為什麼很多人覺得 Apollo 可以取代 redux。

React Query​

我其實沒用過 GraphQL。 我們公司本來就有 REST API,也沒遇到 over-fetching 的問題, 一切都運作得很好。 要換成 GraphQL,後端也要大改,沒那麼簡單。

但我一直很羨慕前端抓資料可以這麼簡單, 還能自動處理 loading 跟 error 狀態。如果 REST API 也有這種工具就好了...

這時候 React Query 出現了。

由 Tanner Linsley 在 2019 年底開發, React Query 把 Apollo 的優點帶到 REST 世界。 它可以搭配任何回傳 Promise 的函式,並採用 stale-while-revalidate 快取策略。 這個函式庫預設值很聰明,盡量讓資料保持最新, 同時又能讓使用者盡快看到畫面, 有時甚至感覺像是即時顯示,UX 超棒。 而且它也很彈性,預設不夠用時可以自訂各種設定。

這篇文章不是 React Query 的入門教學。

官方文件已經把概念講得很清楚, 還有很多 教學影片, Tanner 也有出 React Query Essentials 課程, 想學可以直接去看。

我想分享的是一些超越文件、實際用過才會發現的技巧, 這些是我這幾個月在工作上大量用 React Query, 還有在 Discord、GitHub 討論區幫人解答時累積的心得。

預設值說明​

我覺得 React Query 的 預設值 設計得很棒,但有時會讓新手搞不清楚狀況。

首先:React Query 不會在每次 re-render 時都呼叫 queryFn,即使 staleTime 預設是 0。 你的 app 可能因為各種原因一直 re-render,如果每次都抓資料會瘋掉!

請永遠為 re-render 而寫程式,而且會 re-render 很多次。我稱這叫 render resiliency。

如果你遇到預期外的 refetch,通常是因為你剛把視窗 focus 回來, React Query 預設會 refetchOnWindowFocus: 當使用者切到別的分頁再回來時,會自動在背景抓一次資料, 如果 server 上有新資料,畫面就會自動更新。 這一切都不會顯示 loading spinner, 如果資料沒變,元件也不會 re-render。

開發時這個行為會更明顯, 因為你常常在瀏覽器 DevTools 跟 app 之間切換 focus,也會觸發抓資料。

自 React Query v5 起,refetchOnWindowFocus 不再監聽 focus 事件, 而是只用 visibilitychange。這樣在開發時就不會一直被打擾, 但在正式環境還是能自動更新畫面。

再來,gcTime 跟 staleTime 常常被搞混,這邊簡單說明:

  • staleTime:資料從「新鮮」變成「過期」的時間。 只要還在新鮮期,資料都只會從快取拿,不會發出網路請求! 如果過期了(預設是馬上),還是會先從快取拿資料, 但在某些情況下會在背景自動 refetch。
  • gcTime:沒有元件在用這筆資料時,多久後從快取移除。預設是 5 分鐘。 只要沒有任何 observer(元件)在用這個 query,資料就會進入 inactive 狀態。

大部分情況下,如果你要改設定,通常是調 staleTime, gcTime 幾乎很少需要動。 官方文件有很好的範例說明。

gcTime 以前叫 cacheTime,v5 之後改名比較貼切。

善用 React Query DevTools​

這個工具超級有幫助,可以讓你清楚看到每個 query 的狀態, 也能直接看到快取裡有什麼資料,debug 超方便。 另外,建議你在瀏覽器 DevTools 裡把網路速度調慢, 這樣更容易觀察到背景 refetch,因為 dev server 通常太快了。

把 query key 當成依賴陣列​

這裡指的是 useEffect 的依賴陣列,應該大家都很熟。

為什麼這兩個很像?

因為 React Query 只要 query key 變了就會自動 refetch。 所以如果我們把變數參數傳給 queryFn,通常也會希望那個值變動時自動抓新資料。 不用自己寫一堆 effect 來手動 refetch,直接把參數放進 query key 就好:

// feature/todos/queries.ts

type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
const response = await axios.get(`todos/${state}`)
return response.data
}

export const useTodosQuery = (state: State) =>
useQuery({
queryKey: ['todos', state],
queryFn: () => fetchTodos(state),
})

假設 UI 有一個 todo 列表跟篩選器, 我們會用 local state 存篩選條件, 只要使用者切換篩選,local state 變了, React Query 就會自動 refetch,因為 query key 變了。 這樣就能讓篩選條件跟 query function 保持同步, 跟 useEffect 的依賴陣列很像。 我幾乎沒遇過把變數傳給 queryFn 但沒放進 queryKey 的情境。

新的快取條目​

因為 query key 是快取的 key, 所以從 'all' 換到 'done' 會產生新的快取條目, 第一次切換時會有 loading 狀態(可能會看到 loading spinner)。 這不是很理想,所以如果可以的話, 我們可以用 initialData 預先填快取。 這個例子就很適合,因為我們可以在 client 端先過濾 todos:

// pre-filtering

type State = 'all' | 'open' | 'done'
type Todo = {
id: number
state: State
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
const response = await axios.get(`todos/${state}`)
return response.data
}

export const useTodosQuery = (state: State) =>
useQuery({
queryKey: ['todos', state],
queryFn: () => fetchTodos(state),
initialData: () => {
const allTodos = queryClient.getQueryData<Todos>([
'todos',
'all',
])
const filteredData =
allTodos?.filter((todo) => todo.state === state) ?? []

return filteredData.length > 0 ? filteredData : undefined
},
})

這樣每次切換篩選條件時,如果還沒抓到資料, 就先從 'all todos' 快取裡過濾出來給使用者看, 等背景抓完再更新。

這樣 UX 會好很多,只要幾行程式碼。

保持 server state 跟 client state 分離​

這跟我上個月寫的 這篇文章 (putting-props-to-use-state) 有關: 如果你是用 useQuery 拿到資料,盡量不要再把它放進 local state。 主要原因是這樣會自動放棄 React Query 幫你做的背景更新, 因為 local state 的「副本」不會自動跟著更新。

如果你只是要拿資料來初始化表單, 等資料抓到再 render 表單,這樣做沒問題。 這種情境下背景更新通常不會有新資料, 就算有,表單也已經初始化了。 如果你是這種需求,記得把 staleTime 設成 Infinity,避免不必要的 refetch:

// initial-form-data
const App = () => {
const { data } = useQuery({
queryKey: ['key'],
queryFn,
staleTime: Infinity,
})

return data ? <MyForm initialData={data} /> : null
}

const MyForm = ({ initialData }) => {
const [data, setData] = React.useState(initialData)
...
}

如果你要顯示的資料還要讓使用者編輯,這個原則會比較難遵守, 但這樣做有很多好處。

重點就是:永遠不要把 React Query 拿到的值複製到 local state, 這樣才能確保你看到的永遠是最新資料。

enabled 選項超好用​

useQuery 有很多自訂選項,enabled 超級強大, 可以讓你做很多很酷的事(雙關 intended XD):

  • 依賴查詢(Dependent Queries)
    先用一個 query 抓資料,等有結果後再讓第二個 query 執行。
  • 動態開關查詢
    有些 query 會定時抓資料(用 refetchInterval),但如果彈跳視窗開著,可以暫時停掉,避免畫面在背後更新。
  • 等待使用者輸入
    有些查詢條件還沒設定好時,先不要啟用 query,等用戶按下「查詢」再啟用。
  • 用戶輸入後停用查詢
    例如有草稿資料要優先顯示,就可以暫時停用 query。

不要把 queryCache 當 local state manager​

如果你用 queryClient.setQueryData 操作 queryCache, 應該只用在 optimistic update 或 mutation 後寫入 server 回傳的資料。 因為每次背景 refetch 都可能覆蓋掉你寫進去的資料, 所以 local state 還是建議用 useState、zustand、redux 這類工具。

建立自訂 hook​

就算只是包一層 useQuery,寫成自訂 hook 也很值得,因為:

  • 可以把資料抓取邏輯從 UI 拆開,但又跟 useQuery 放在一起。
  • 所有用到同一個 query key(還有型別定義)都集中在一個檔案。
  • 以後要調設定或加資料轉換,只要改一個地方。

上面 todos 查詢 (#treat-the-query-key-like-a-dependency-array) 就是個例子。

最後更新:2023-10-21

本文原始連結​

Practical React Query