為什麼 TanStack Query 的 useQuery 沒有像 mutate 的呼叫型 lazy query API?我可以用 useMutation 讀資料嗎?

Published on Written by

最近上班在進行 code review 時發現的一個 TanStack Query 的相關議題:為什麼有時候明明是 GET 後端資料,卻好像用 useMutation 比較好維護。重新檢視 TanStack Query 後發現,「useQuery 還是 useMutation?」這個問題背後,是一套看待「前端和後端同步」的心智模型。這篇會從這個日常開發會遇到的問題出發,介紹這個模型。

Note

本篇以 TanStack Query 為切入點進行討論,但這套心智模型不綁工具:SWR、RTK Query、甚至自行實作的資料層,面對的都是同一個問題。

跟後端要下載連結,該用哪個 hook?

useQueryuseMutation 的名字暗示了一組直覺:讀資料用 useQuery,寫資料用 useMutation。在典型 CRUD 情境下這個直覺剛好都對:列表、詳情頁是讀,新增、編輯、刪除是寫。

直到出現這種請求:報表列表頁上,使用者點某一列的「下載」,前端呼叫後端的 get-report-download-link,拿到一個有時效的下載連結(例如 S3 presigned URL)後開新視窗。API 名字就是 get- 開頭,回傳一個字串,怎麼看都是「讀一個連結」,直覺就會想用 useQuery

useQuery 沒有提供任何「觸發」的 API:元件一 mount 它就自己去抓了,需求卻是按了按鈕才拿。於是常見的 workaround 就出現了:

// ❌ 常見 hack:把 useQuery 硬改造成事件觸發
const { refetch } = useQuery({
queryKey: ['report-download-link'],
queryFn: () => getReportDownloadLink(reportId), // reportId 來自 closure,不在 key 裡
enabled: false,
})
<button onClick={() => refetch()}>下載</button>

這個寫法有兩個實際的坑,官方文件都有明說:

  • refetch() 不能帶新參數,它只會用同一個 query key 重跑。上面 reportId 藏在 closure 裡,看似能動,但快取裡那筆 ['report-download-link'] 的內容取決於「上次按的是哪份報表」,資料的身分變成事件歷史,別的元件讀到它時沒辦法知道它對應哪份報表。
  • 永久 enabled: false 等於退出這個 library 的核心機制:自動 refetch、deduplication、invalidation 全部失效。

問題不是 code 寫得不夠好,而是一開始就選錯了工具,這種請求本來就不是 useQuery 設計來處理的。

要看出不適合的原因,並知道該換成什麼,我們得先退一步,問一個更根本的問題:API 請求這件事,對前端而言究竟代表什麼?

前端眼中的兩種請求:同步快照與執行動作

一個基本的原則是,在 Web App 架構中,絕大多數重要的資料(使用者是誰、訂單內容是什麼、有哪些文章……),事實的來源(source of truth)在後端

準確來說,前端拿到的不是事實本身,而是事實的某個時刻的快照;從取得的那一刻起,它就可能與後端事實的最新狀態產生落差。

因此前端必須管理快照和事實間的關係:這份快照對應哪個事實(身分)、可以接受多舊(新鮮度)、什麼時候該重新取得(更新策略)。

在這個前提下,我們可以把前端和後端之間的互動分成兩種:

  • 同步狀態:「X 現在長什麼樣?」後端回一份快照,前端存下來,並讓這份快照遲早跟上遠端。
  • 執行動作:「請執行這件事。」後端執行,回報這一次執行的結果。

而執行完動作也會有一個後果:手上某些快照可能不是最新的遠端狀態了。改了使用者資料,那「使用者列表」、「使用者詳情」的快照可能就過期了。

所以同步模型裡,通常還有第三步:動作完成後,哪些快照會過期需要更新

flowchart LR
    S[("後端<br/>source of truth")]
    subgraph F["前端"]
        U["UI 元件"]
        C["快照層(快取)"]
    end
    U -- "① 訂閱事實" --> C
    C -- "② 同步回快照(隨時可重問)" --> S
    U -- "③ 執行動作" --> S
    U -- "④ 動作完成:宣告快照過期或觸發更新快照" --> C

④ 之後,快照層會自動把過期的快照帶回 ②。讀與寫因此接在同一個系統裡,元件不必自己記得「改完這個要重抓哪些」。

就算什麼 library 都不裝,用 useState 存回應、useEffect 發請求,我們也已經在手寫一個快照層,存著 server 回應的那些 state 就是快照,只是身分、新鮮度、更新策略全都要自己管理。library 的價值就是幫忙把這件套東西做完整。

回到 TanStack Query 來看,它就是這個模型的一個實作:

  • useQuery 是快照管理器(①②:身分=query key、新鮮度=staleTime、更新策略=refetch on focus/reconnect)
  • useMutation 是動作執行器(③④:觸發動作、完成後用 invalidateQueries 宣告過期)。

維護者自己的說法是:React Query 不是 data fetching library,是 async state manager

所以,比較好的理解這兩個 API 的方法,不是用讀、寫來分類,而是用**「同步快照」和「執行動作」**來分。老實說,這也表示 useQueryuseMutation 的名字有點誤導:useQuery 不是「查詢」,而是「同步快照」;useMutation 不是「變更」,而是「執行動作」。我猜測這或許是因為這套模型也是維護者在實作過程中才摸索出來並確定其設計哲學的,但 API 名字已經定了,改不掉。

同步,還是執行?

再更詳細順一次,我們怎麼判斷每個具體請求是前面說的哪一種?一個請求是屬於快照還是執行?可以分兩層來判斷:

第一層:這個請求有沒有要求後端改變什麼?

有,那它定義上就是執行:client 主動要求一次改變,回應是這次執行的結果。CRUD 中的 Create、Update、Delete,都在這一層就能判定。

第二層:沒有要求改變的請求,還要進一步確認前端要跟這個回應維持什麼關係:

遠端那個狀態變了之後,這個畫面遲早該自己跟上嗎?那這個回應就是快照,用同步的(useQuery)。 還是這個回應是這一步流程的一次交付物,也就是拿到手這一步就完成,「過期」對它根本沒有意義?那它就是執行useMutation)。

「遲早」是關鍵字。同步不是隨時保持一致,這套模型容許暫時的不同步,但過期的快照就是在「待修復」的狀態,系統會在適當時機(refocus、重連、invalidate、下次被讀到)把它補回來。

staleTime 這個參數就直接反映了這件事:它用來宣告「這份快照容許多舊」。

交付物剛好相反:它沒有「過期」可言:報價單不會變舊,它記的永遠就是「那一次執行算出了什麼」。要新的數字,就是新的一次執行。

第二層的地雷區,是後端認知是「讀」資料,但對前端來說是單次「執行」的請求。名字取 get- 開頭、HTTP 動詞用 GET、文件寫「查詢」,這些標記說的是後端眼中「這是一次讀取」,可以是對的,但不該作為分類依據:真正的判斷點是前端拿這個回應做什麼。開頭的 get-report-download-link 對後端來說確實是「取得連結」,對前端來說卻是一次交付的請求。

不確定的時候,也有一種方式是可以想像,如果這個請求自動重發一次(快照層真的會這麼做:refocus、斷線重連、快照過期)會怎麼樣。如果它只是讓畫面顯示更新的內容(即使值變了,因為別人剛好寫入),那沒事。但如果重發等於又多做了一次事(又簽了一個連結、又消耗一組 OTP、又產一份檔案),或是會把使用者手上正要用的資料換掉,而且這個換掉對使用者體驗是有害的,那它就不該使用 useQuery 的同步管理機制。

TanStack Query 為什麼不給 useQuery 觸發型 API?(像 mutate())

理解了這個心智模型,就能看懂這個設計,維護者的理由是什麼。

設計決策:抓取的責任全部交給快照層,元件只宣告「我依賴哪個狀態」,什麼時候發請求、發幾次、跟誰共用,由快照層全權決定。維護者的用詞是「queries are declarative」。

對應到 API 上就是兩條配套的限制:

維護者的說明可以整理成兩件事:

第一,快照層要能自己決定怎麼抓。整套自動化(refocus 時 refetch、斷線重連後 refetch、staleTime 過期後背景更新、invalidateQueries 觸發重抓、多個元件共用同一次請求),每一項都是 library 在 event handler 沒有介入的時刻自行重發請求。參數要是在呼叫當下才給,「該怎麼問」就有一部分卡在 event handler 裡,快照層就沒辦法自己重問了。

第二,快取的身分不能被覆寫。「不同參數的回應,存在不同 key 底下」是這個 library 的核心能力;想像中的 refetch(新參數) 會把不同參數的回應寫進同一格快取,用 id 2 重抓,就把 id 1 的資料蓋掉了。這正是開頭 hack 的第一個坑。社群反覆許願這個功能,維護者的回覆從 20222025 沒變過:這種需求其實不是 refetch,而是對另一個 id 做一次 new fetch;new fetch 在這個模型裡的拼法,就是新的 key。

所以,我認為不該把 enabled 當成觸發開關來理解,而是「這個狀態目前是否需要同步」的宣告。例如:

const [submittedKeyword, setSubmittedKeyword] = useState('')
const { data } = useQuery({
queryKey: ['search', submittedKeyword],
queryFn: () => search(submittedKeyword),
enabled: submittedKeyword !== '',
})
<form onSubmit={() => setSubmittedKeyword(input)}>

在這個範例中,當 submittedKeyword 是空字串時,這個狀態不需要同步,query 不會自動發請求;一旦使用者送出表單,submittedKeyword 變成非空字串,這個狀態就需要同步了,query 會自動發請求。官方把這種範例叫作 lazy query

同樣地,refetch() 也該在這個同步快照管理機制中理解:「對這個目標後端狀態,我現在就要更新前端快照」。

決策順序

把兩層判斷攤成操作步驟,由上往下問:

flowchart TD
    Q1{"請求有要求後端改變什麼嗎?"}
    Q2{"遠端狀態變了,<br/>畫面遲早該自己跟上嗎?"}
    Q3{"UI 需要顯示<br/>pending/error 狀態嗎?"}
    M1["① useMutation<br/>+ onSuccess 裡 invalidateQueries"]
    QY["② useQuery<br/>所有參數進 query key"]
    M2["③ useMutation<br/>(讀成 useAction)"]
    AW["④ 直接在 handler 裡 await"]
    Q1 -- "有(執行)" --> M1
    Q1 -- "沒有(讀取形)" --> Q2
    Q2 -- "是(同步)" --> QY
    Q2 -- "否(一次交付)" --> Q3
    Q3 -- "要" --> M2
    Q3 -- "不用" --> AW
  1. 請求要求改變遠端狀態(新增、更新、刪除)→ useMutation,並要做同步模型的第 ④ 步。

  2. 讀取形,而且遠端狀態變了、畫面遲早要跟上useQuery

  3. 讀取形,但回應是一次交付物、UI 需要 pending/error 狀態useMutation,即使後端把它定義成「讀」資料。

    開頭的「拿下載連結」在這裡得到正解:

    const getDownloadLink = useMutation({
    mutationFn: (reportId: string) => api.get(`/reports/${reportId}/download-link`),
    onSuccess: ({ url }) => window.open(url),
    })
    <button
    onClick={() => getDownloadLink.mutate(reportId)}
    disabled={getDownloadLink.isPending}
    >
    {getDownloadLink.isPending ? '產生連結中…' : '下載'}
    </button>

    需求全部符合:mutate(params) 就是事件觸發、參數在呼叫當下帶入;結果不會進到共用快取,state 只屬於這個 hook 實例。

    預設不自動 retry(query 預設會),對執行來說這才是對的,連結簽發或匯出不該自行默默重跑三次。還附帶 isPending(順手擋 double-submit)和 error

    唯一的彆扭是名字:對 read-only 請求寫 useMutation 感覺怪。把它讀成 useAction 就通了,這也是維護者本人的建議用法

  4. 一次交付、連狀態都不需要顯示 → 直接在 handler 裡 await,別引入機器。

    同一個下載,如果連結產生得夠快、連 spinner 都不打算顯示:

    const handleDownload = async () => {
    const { url } = await api.getReportDownloadLink(reportId);
    window.open(url);
    };

    TanStack Query 的價值在快照管理和狀態機 boilerplate。兩個都用不到時,一個 async function 就是正確答案。第 3 步和第 4 步的差別不在語意(都是執行),只在 UI 需不需要那台狀態機。

回到標題的問題。其實會產生疑惑的根本原因是 Tanstack Query API 的命名,沒有讓我們直觀地理解它背後的設計理念和心智模型,甚至會誤導成另一套心智模型。

如果我們把 useQuery 理解成「同步快照」、useMutation 理解成「執行動作」,整套 TanStack Query API 的設計:為什麼沒有觸發、為什麼 refetch 不能帶參數、為什麼 mutation 不自動 retry,就全部說得通了。

延伸閱讀

  • React Query as a State Manager(TkDodo,TanStack Query 維護者),本篇「快照」模型的主要出處:server state 是某個時刻的快照、stale-while-revalidate、用 staleTime 控制新鮮度。
  • Mastering Mutations in React Query(同上),「useQuery is declarative, useMutation is imperative」的完整說明,含 mutation 生命週期與 invalidation 實務。
  • LeeLuciano 最近在臉書台灣前端社群看到 signal-kernel 作者 Lee Luciano 討論前端非同步資料狀態管理的一系列文章,經由他的觀點的啟發,讓我重新想一次 TanStack Query 隱藏在實作背後的觀點,因此有這篇文章。

圖表瀏覽器

100%