React Query Tutorial

前言

data fetching 在 React 裡一直是我感到不太自在的一塊,主要是來自於我學藝不精,我知道 React 已經有部分的 pattern 在處理這一塊了,目前最常用到的就是 Redux + Redux middlewares(Redux thunk、Redux saga 等等),前陣子則是開始試著用 custom hook 來處理這塊,感覺很不賴,但比如 cache 等等的事情依然麻煩,所以我在尋找有沒有更好的作法。

最近看到了 React Query,使用起來覺得很讚讚,文件說明清楚、API 少少的很簡單,很符合我心目中好用的 data fetching hook,不過目前也沒有用很深,更多的坑或是應用場景還沒有遇到,所以使用心得可能還要再一陣子才會有,這篇的目的就是做做筆記,提供日後的自己回顧。

👉 為何要使用?如果不用它會怎麼樣?

如果使用 Redux 一段時間後,應該會發現 Global State 大致上可以分成兩種類型,一種是關於整個 Web App 的內部狀態,比如 toggle、alert,這類狀態不需要 cache,又或是其他的處理;另一種則是需要經過非同步獲取的資料,像是 call API 才能拿到的 data、登入的 token 等等。

原本 Redux 的社群為了處理這類非同步的 state,誕生了像是 redux-thunk、redux-saga、redux-observable 等 middlewares 來處理相關邏輯,為的是讓邏輯分工更為明確,掌管 View 的 Component 就負責顯示畫面,call API 邏輯就在 middlewares 中處理,但時間一久你還是會發現 reducers 中分散了各類的 state,也就是同步的 state 和非同步的 state 混雜再一起。

React Query 就是因應而生的,作者將 global state 區分為 Client state 和 Server State,Client state 就是前面段落說的 Web App 內部狀態,你可以保證他會是最新的狀態,而 client state 處理起來也相對簡單;Server State 則是需要經過非同步獲取的 state,像是 call API 得到的 data,你無法保證他是最新的,並且常常需要經過比較複雜的處理,比如 cache、重新 fetch、抑或是 background updating 等等的,而 React Query 就是為了更好的處理 Server State 而出現。

值得注意的是,React Query 和 Redux 不是二選一的選項,而是可以共存的,如同作者 Tanner Linsey 在 reddit 所言:

React Query is significantly different from using Redux. Redux is a global state manager with it’s own context etc. React Query to some extent is also a global state manager for async data. If you were to integrate the two, you would move all of your async data out of redux into React Query and only use Redux for non-async-data global application state.

作者 Tanner Linsey 說到他在開發 Nozzle.io 的過渡時期就是採用這樣的方式,而在最後 Redux 也被拔掉了,而是改用 useReducer + React Context 來管理 global UI state。

如果覺得原本的 Redux + Redux middleware 生態系使用的很好,專案邏輯架構蠻清晰的,也有其他方式處理 cache、重新 fetch 這類的事情,那不使用當然也沒問題;不過 React Query 是一個新的選擇,讓你用簡單的幾個 API 就能夠簡單對付麻煩的 cache 以及更好的管理 Server State,如果有興趣的話,可以給它個機會試試(與 React Query 用途相似的還有 React SWR 以及 GraphQL 的 Apollo Client

另外,有興趣的話也可以看看作者的 影片介紹 ,這部影片把 React Query 誕生的目的講解非常清楚,同時也用了 React Query 重構小專案,讓開發者們能夠看看重構前後的差異(雖然我覺得影片中的螢幕太小了,很難看清楚 code)

👉 Getting Started

接下來想用 Star Wars API 建立的簡單 project 來介紹 React Query 的各個 API,這邊有建好的 codesandbox template

開始前的須知 - React Query 的預設行為

  1. Query results 渲染在畫面上的結果會在他們被 resolved 後馬上過期,並且當結果再次被 render 或是被使用時,會自動在背景 refetch,並且開發者也可以改變 staleTime 這個設定來調整過期的時間(預設為 0 milliseconds)。
  2. Query results 在 Component unmounted 後仍然會被 cached,garbage collected 預設的時間為五分鐘,假如這五分鐘再次被使用到的話,就會從 cached 取出這些 results 使用,如果要更改預設值的話,可以從 cacheTime 來做調整(預設為 1000 * 60 * 5 milliseconds)。
  3. Stale queries 會在使用者 refocus 瀏覽器的 window 或是瀏覽器重新連線時 refetch,如果不想有這些行為的話,也可以從 refetchOnWindowFocusrefetchOnReconnect 這兩個選項來做調整
  4. Queries 如果失敗的話,會 retried 三次,當三次都失敗的時候 status 才會顯示 error,如果要更改這類設定的話,可以從 retryretryDelay 來調整。
  5. Query results 在預設情況下會是 structurally shared 的,以檢測 data 是否有更改,如果沒有的話,references 就會保持不變,以保證 useCallbackuseMemo value 的穩定。structurally shared 只對 JSON-compatible values 有作用,其他的 value types 都會被視為有變化的,如果你發現效能上的問題來自於太龐大的 response,你可以把 config.structuralSharing 這個選項給 disabled,那如果你在處理 non-JSON compatible values 的話,仍然想監測 data 是否有變化的話,可以從 config.isDataEqual 做更客製化的設定。

useQuery

首先介紹第一個 API,useQuery,目前我們有幾個 component,App.jsxNavbar.jsxPlanets.jsxPeople.jsx 這幾個 Component,我們的主角就是 Planets.jsxPeople.jsx,其他不重要,Navbar.jsx 就是用來切換 Planets 和 People 用的導覽列。

接下來,我們先到 Planets.jsx 這支檔案,可以看到以下:

import React from "react";

export default function Planets() {
  return (
    <div>
      <h2>Planets</h2>
    </div>
  );
}

毫無反應,就是一支無聊的 Component,所以我們來設定一下現在的首要任務目標,以及預計如何執行:

⛳️ 任務目標

發 request 從 'https://swapi.dev/api/planets/' 這個 endpoint 取得 response 後,把各個星球的資料 render 到畫面上。

⛳️ 如何執行

使用 fetchuseQuery 達成任務!

接著我們就開始吧,首先,useQuery 使用上的參數有兩個:

  • A unique key for the query
  • An asynchronous function (or similar then-able) to resolve the data

第一個參數必須是一個 unique 的 key,這個 key 可以是字串、陣列、或是物件,這邊為了簡單起見,就先隨便用個字串 planets;第二個參數則是 fetch data 時會用到的非同步 function,這個 function 就是平常你在 fetch data 時會寫的 function,所以我們就先寫成這樣:

import React from 'react'
import { useQuery } from 'react-query'

const fetchPlanets = async () => {
  const res = await fetch('https://swapi.dev/api/planets/')
  return res.json()
}

export default function Planets() {
  const { data, status, isLoading, isError, isSuccess } = useQuery(
    'planets',
    fetchPlanets
  )
  console.log({ data, status, isLoading, isError })
  return (
    <div>
      <h2>Planets</h2>
    </div>
  )
}

很簡單,就是先寫個 asynchronous function,然後把 asynchronous function 當作參數傳入這樣。

接下來,講解一下 useQuery 執行後會回傳的內容:

data:就是 fetch 後會得到的 response

status:分成三個類型的字串,loadingsuccesserror,就是一般我們在 fetch 時會有的三種狀態

isLoading、isSuccess、isError:如果不喜歡 status 的用法的話,可以改用這些 flag

因為我比較習慣 isXXX 這類的 flag 用法,在得到 data 之後我們可以寫成這樣:

import React from 'react'
import { useQuery } from 'react-query'
import Planet from './Planet'

const fetchPlanets = async () => {
  const res = await fetch('https://swapi.dev/api/planets/')
  return res.json()
}

export default function Planets() {
  const { data, isLoading, isError, isSuccess } = useQuery(
    'planets',
    fetchPlanets
  )
  return (
    <div>
      <h2>Planets</h2>
      {isLoading && <div>Loading...</div>}
      {isError && <div>Fetching error</div>}
      {isSuccess &&
        data.results.map((planet) => {
          return <Planet planet={planet} key={planet.name} />
        })}
    </div>
  )
}

噹啷!任務目標簡潔且優雅地完成了!這邊新增的 Planet Component 內容長這樣:

import React from 'react'

export default function Planet({ planet }) {
  return (
    <div className="card">
      <h4>{planet.name}</h4>
      <p>population: {planet.population}</p>
      <p>terrain : {planet.terrain}</p>
    </div>
  )
}

再稍微調整 css 後畫面會這樣:

截圖 2020-09-05 下午3.29.43

畫面很醜我知道,但我盡力了,然後 People.jsx 要做的事情是差不多的,所以就不多贅述,這是完成後的 codesandbox

所以…和 useFetch Hook 差在哪呢?

現在 React Hook 也有人作出了各種 fetch data 相關的 custom hook,useQuery 用起來其實大同小異,但最大的差別是在於它背後幫你處理了 cache、deduping、updating out of date data in the background 等各種事情。

所以你會發現到第一次 fetch 的時候會顯示短暫的 loading 畫面,但之後在「Planet」和「People」切換就順暢無比,因為 data 都從 cached 中取得。

React Query DevTool

React Query 還有貼心的提供 Devtool 提供開發者詳細的檢視每個 query 執行的狀態,使用上也很簡單,首先安裝 react query devtools

yarn add react-query-devtools

接下來在 App.jsx import 進來:

import React, { useState } from 'react'
import { ReactQueryDevtools } from 'react-query-devtools' // 這行
import Navbar from './components/Navbar'
import Planets from './components/Planets'
import People from './components/People'
import './App.css'

function App() {
  const [page, setPage] = useState('planets')
  return (
    <div className="App">
      <h1 className="title">Star Wars Info</h1>
      <Navbar setPage={setPage} />
      <div className="content">
        {page === 'planets' ? <Planets /> : <People />}
      </div>
      <ReactQueryDevtools initialIsOpen={false} /> // 以及這行,initialIsOpen 就是設定是否預設為打開的狀態
    </div>
  )
}

export default App

打開目前 localhost 的頁面就可以看到下面的畫面:

截圖 2020-09-06 下午10.07.38

可以看到它提供了 Data、Query 的詳細設定、目前 Data 是 fresh、fetching、inactive 等等,如果發送了不同的 query,也會顯示每個 query 的狀態:

截圖 2020-09-06 下午10.09.06

用起來蠻像 Redux Devtools 的!設定也超級簡單,安裝後 import 進來就好,介面更是簡潔直觀。

如果想要分頁怎麼做?usePagination!

key

前面有很快速的提到,key 可以提供 strings、array、object 等型態,當我們遇到比較複雜的應用場景時,就可以試著用 array 或 object 來應付這些情形,下面就以分頁做範例。

import React from 'react'
import { useQuery } from 'react-query'
import Planet from './Planet'

const fetchPlanets = async (str, page) => {
  console.log(page) // output: 1
  const res = await fetch('https://swapi.dev/api/planets/')
  return res.json()
}

export default function Planets() {
  const { data, isLoading, isError, isSuccess } = useQuery(
    ['planets', 1], // 傳入陣列 
    fetchPlanets
  )
  return (
    <div>
      <h2>Planets</h2>
      {isLoading && <div>Loading...</div>}
      {isError && <div>Fetching error</div>}
      {isSuccess &&
        data.results.map((planet) => {
          return <Planet planet={planet} key={planet.name} />
        })}
    </div>
  )
}

當我們把 useQuery 的第一個參數 key 改以陣列傳入時,asynchronous function 的參數值就會依照陣列的順序傳入值,以上面 fetchPlanets 的例子來說,它的第一個 parameter 的值就會是 plaents,第二個 parameter 則是 page ,也因為如此,我們多了更多彈性使用的空間。

舉例來說,我們可以開始作分頁了!

import React, { useState } from 'react'
import { useQuery } from 'react-query'
import Planet from './Planet'

const fetchPlanets = async (key, page) => {
  console.log(page)
  const res = await fetch(`https://swapi.dev/api/planets/?page=${page}`) // 新增了 page parameter
  return res.json()
}

export default function Planets() {
  const [page, setPage] = useState(1) // 新增 page state
  const { data, isLoading, isError, isSuccess } = useQuery(
    ['planets', page], 
    fetchPlanets
  )
  return (
    <div>
      <h2>Planets</h2>
      <button onClick={() => setPage(1)}>page 1</button> // 增加一堆 button 改變 page
      <button onClick={() => setPage(2)}>page 2</button>
      <button onClick={() => setPage(3)}>page 3</button>
      {isLoading && <div>Loading...</div>}
      {isError && <div>Fetching error</div>}
      {isSuccess &&
        data.results.map((planet) => {
          return <Planet planet={planet} key={planet.name} />
        })}
    </div>
  )
}

畫面會長這樣:

Sep-06-2020 23-04-08

上面做的改動很簡單,endpoint 新增 page parameter、page state 以及增加改變 page 的 button,但問題也很顯而易見:

  1. 這樣新增 button 北七北七的(但這單純是我懶)
  2. 切換分頁的時候,會不停顯示 loading,除非 fetch 過一次,才會直接抓 cache 的 data 而不顯示 loading

不停切換 loading 和新頁面,雖然一般來說是能夠容忍的,但 UX 似乎可以更好,為了增進 UX,接下來要使用 React Query 提供的另一個 API,就是 usePaginatedQuery!

usePaginatedQuery

前面使用 useQuery 可以發現到它運作上沒什麼問題,很多的 Web App 也是這樣運作的,但美中不足的地方是 UI 會一直在 successloading 之間切換,不過如果使用 usePaginatedQuery,則有幾個不同的地方:

  • usePaginatedQuery 不使用 data,而是改成 resolvedDataresolvedData 是會是上一次成功 fetch 的 query result,當你發送新分頁的 request 時,resolvedData 直到成功 fetch 前都會保持原本的 data,fetch 成功並拿到新的 data 後,resolvedData 才會變成最新的 data。
  • 如果真的需要發送 request 的當下就要拿到 data,可以使用 latestData ,當你發送 request 到被 resolved 前,latestData 會是 undefined 直到 query resolved,所以運作方式就蠻像原本 useQuerydata

所以我們把原本 code 改動一下:

import React, { useState } from 'react'
import { usePaginatedQuery } from 'react-query'
import Planet from './Planet'

const fetchPlanets = async (key, page) => {
  const res = await fetch(`https://swapi.dev/api/planets/?page=${page}`)
  return res.json()
}

export default function Planets() {
  const [page, setPage] = useState(1)
  const {
    resolvedData,
    latestData,
    isLoading,
    isError,
    isSuccess,
  } = usePaginatedQuery(['planets', page], fetchPlanets) // 從 useQuery 改成使用 usePaginatedQuery
  return (
    <div>
      <h2>Planets</h2>
      <button onClick={() => setPage((prevPage) => Math.max(prevPage - 1, 1))}> // 修改一下 button
        Previous Page
      </button>
      <div>{page}</div>
      <button
        onClick={() =>
          setPage((prevPage) =>
            !latestData || !latestData.next ? prevPage : prevPage + 1
          )
        }
      >
        Next Page
      </button>
      {isLoading && <div>Loading...</div>}
      {isError && <div>Fetching error</div>}
      {isSuccess &&
        resolvedData.results.map((planet) => {
          return <Planet planet={planet} key={planet.name} />
        })}
    </div>
  )
}

附註一下,這邊 Previous Page Button 使用 Math.max 的目的是避免在第一頁的時候會被持續扣減到小於 1。

這時候操作上會變得像是這樣:

Sep-06-2020 23-42-42

好,改完之後我覺得 UX 有沒有變好呢?…我保持懷疑的態度,原因是原本 UI 的確是會在 loading 和完整的畫面切換(如果要有更好的 UX 應該會是用 Skeleton);而改用 usePaginatedQuery 後,在 data 完成前,畫面都會保持原樣,所以使用者也可能會懷疑自己的操作到底有沒有成功,有 loading 的話至少還能知道自己操作有成功,只是需要等待。

👉 結語

如同標題所言,這篇就是 Tutorial,介紹最基本的 API 和使用方式,但還有很多地方沒提及,比如更詳細的官方文件內容、useMutation、React Query 的 cache 怎麼運作等等。

待研究的問題

  • useMuation 以及官方文件更詳細的內容
  • data 會 cache 在哪裡?
  • 如果設定一個 staleTIme 後,在過期以前如果資料更新,React Query 會怎麼做?
  • 如果要共用一份 data,只能用 custom hook 嗎?那假如有 Component A 和 B,都要用同一份 data,是否只能 call 兩次 API 呢?如果不想 call 兩次 API,那是不是只能放進 Redux 或 Context 的 Global State 呢?

這些問題日後就來研究。

👉 Refercences

React Query Docs

The Net Ninjia - React Query Tutorial