「useQuery 還是 useMutation?」這個問題的答案背後,是一套看待「前端和後端同步」的心智模型。這篇會從這個日常開發一定會遇到的選擇問題出發,介紹這個模型。
本篇以 TanStack Query 為例,但這套模型不綁工具——SWR、RTK Query、甚至你自己手寫的資料層,面對的都是同一個問題。
拿個下載連結,該用哪個 hook?
useQuery 和 useMutation 的名字暗示了一組直覺:讀資料用 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、去重、invalidation 全部失效,官方直接警告這是從宣告式退回命令式。
問題不在 workaround 寫得不夠好,在於一開始就選錯了工具——這種請求本來就不是 useQuery 服務的對象。
要看出它為什麼不是、又該用什麼,得先退一步看清楚:前端和後端之間,到底在同步什麼?
退一步:前端和後端之間,同步的是什麼?
對絕大多數重要的資料——使用者是誰、訂單內容是什麼、有哪些文章……——事實的來源(source of truth)在後端。前端手上的任何一份 server 資料,都只是某個時刻的快照,拿到的瞬間就開始過期。
前端沒有能力「持有」這些事實,只能管理快照:這份快照對應哪個事實(身分)、多舊算太舊(新鮮度)、什麼時候該重新要一份(更新策略)。
在這個前提下,前端跟後端只有兩種對話:
- 同步狀態——「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——它管的不是「怎麼抓」,是「快照的生命週期」。
分界線:同步,還是執行?
我們怎麼判斷每一個請求都落在前述模型兩種語意的哪一邊?一個請求是快照還是執行?判斷分兩層:
第一層:這個請求有沒有要求後端改變什麼?
有——下單、修改、刪除——那它定義上就是執行:client 主動要求一次改變,回應是這次執行的報告。定案,不需要任何進一步判斷。
CRUD 寫入全部在這一層歸位——「寫資料用 useMutation」的直覺在這半邊本來就是對的,名字騙人的只有另一半邊。
第二層:沒有要求改變的請求(讀取形),要進一步問的是,你要跟這個回應維持什麼關係:
遠端那個狀態變了之後,這個畫面遲早該自己跟上嗎?——那這個回應是快照,用同步的(
useQuery)。 還是這個回應是這一步流程的一次交付物——拿到手這一步就完成,「過期」對它根本沒有意義?——那它是執行(useMutation)。
「遲早」是關鍵字。同步不是隨時保持一致——這套模型容許暫時的不同步,但過期的快照處在「待修復」的狀態,系統會在適當時機(refocus、重連、invalidate、下次被讀到)把它補回來。
staleTime 這個參數就是這個精神的直接證據:它讓你宣告「我容許這份快照多舊」。
交付物則相反:它沒有「過期」可言——報價單不會變舊,它永遠忠實記錄「那一次執行算出了什麼」。要新的數字,就是新的一次執行。
第二層的地雷區,是後端認知是「讀」資料,但對前端來說是單次「執行」的請求。名字取 get- 開頭、HTTP 動詞用 GET、文件寫「查詢」——這些標示表達的是後端眼中「這是一次讀取」,可以是對的,但不是你的判斷依據:分類看的是前端拿這個回應做什麼。開頭的 get-report-download-link 對後端來說確實是「取得連結」,對前端來說卻是一次要求交付。
這種請求比想像中多。不確定的時候,想像這個請求會被系統在你不知情時自動重發一次(快照層真的會這麼做:refocus、斷線重連、快照過期)。
如果重發請求,只是讓畫面顯示更新的內容——即使值變了,因為別人剛好寫入——那沒事,這正是同步要的效果。但如果重發等於又做了一次事(又簽一個連結、又消耗一組 OTP、又產一份檔案),或是把使用者手上正要用的資料偷偷換掉是不對的,它就不是 query:
| 例子 | 自動重發一次會怎樣 |
|---|---|
| 點「下載」拿 S3 presigned URL | 又簽了一個新連結;而快取到的舊連結幾分鐘就過期 |
| 取 OTP、CAPTCHA challenge | 又消耗了一組;一個 challenge 只能用一次 |
| 結帳頁的運費、報價 | 使用者正要同意的數字被背後換掉 |
| 送出前檢查名稱重複、Email 是否已註冊 | 三分鐘前的「可用」不代表現在可用,快取到它反而是 bug |
| 設定頁的「測試連線」按鈕 | 又測了一次;「上次測通了」的快取是誤導 |
| 點「匯出 CSV」、「產生預覽」 | 伺服器又產生了一份新結果——「讀取」其實有副作用 |
為什麼 TanStack Query 設計 useQuery 不給觸發型 API?
有了這個心智模型,回頭看開頭的 hack,就能看清這是一個什麼樣的設計,維護者的理由是什麼。
設計決策:抓取的責任完整劃給快照層——元件只宣告「我依賴哪個狀態」,什麼時候發請求、發幾次、跟誰共用,由快照層全權決定。維護者的用詞是「queries are declarative」。落到 API 上是兩條配套的限制:
- 請求需要的所有參數,都必須放進 query key——維護者的原話:「query key 定義了
queryFn抓資料需要的全部依賴」。key 是快照的身分證:['users', id]唯一決定「這是哪個狀態、該怎麼問」。 - 不提供「事件發生時呼叫、參數當下傳入」的觸發函式;連
refetch()都被定義成「用同一組參數重放」,刻意不收新參數。
決策的依據,維護者的說明可以整理成兩件事:
第一,快照層要能獨立行使抓取責任。整套自動化——refocus 時 refetch、斷線重連後 refetch、staleTime 過期後背景更新、invalidateQueries 觸發重抓、多個元件共用同一次請求——每一項都是 library 在你不在場的時刻自己重發請求。參數要是在呼叫當下才給,「該怎麼問」就有一部分被扣在事件 handler 手裡,快照層無從獨立重問。
第二,快取的身分不能被覆寫。「不同參數的回應,存在不同 key 底下」是這個 library 的核心能力;假想中的 refetch(新參數) 會把不同參數的回應寫進同一格快取——用 id 2 重抓,就把 id 1 的資料蓋掉了。這正是開頭 hack 的第一個坑。社群反覆許願這個功能,維護者的回覆從 2022 到 2025 沒變過:你要的其實不是 refetch,是對另一個 id 的一次 new fetch——而 new fetch 在這個模型裡的拼法,就是新的 key。
決策推到使用端的結果:「事件觸發抓資料」在這個模型裡被強制拆成兩段:
事件 → setState(改變「我在同步哪個狀態」)→ key 變了 → 快照層自己去問
const [submittedKeyword, setSubmittedKeyword] = useState('')useQuery({queryKey: ['search', submittedKeyword],queryFn: () => search(submittedKeyword),enabled: submittedKeyword !== '',})// 事件只負責改 state<form onSubmit={() => setSubmittedKeyword(input)}>
在這個拆法裡,enabled 的語意就清楚了:它不是觸發開關,而是「這個狀態目前還不需要同步」的宣告——userId 還沒有、使用者還沒送出條件。官方把這叫 lazy query。
而 refetch() 唯一正當的語意是:「同一個狀態,我現在就要一份最新快照」——例如 dashboard 上的「重新整理」按鈕。要同步不同的狀態,改 key。
決策順序
把兩層判斷攤成操作步驟,由上往下問:
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
-
請求要求改變遠端狀態(新增、更新、刪除)→
useMutation,並要做同步模型的第 ④ 步。 -
讀取形,而且遠端狀態變了、畫面遲早要跟上 →
useQuery。 -
讀取形,但回應是一次交付物、UI 需要 pending/error 狀態 →
useMutation,即使後端把它定義成「讀」資料。開頭的「拿下載連結」在這裡得到正解:
const getDownloadLink = useMutation({mutationFn: (reportId: string) => api.get(`/reports/${reportId}/download-link`),onSuccess: ({ url }) => window.open(url),})<buttononClick={() => getDownloadLink.mutate(reportId)}disabled={getDownloadLink.isPending}>{getDownloadLink.isPending ? '產生連結中…' : '下載'}</button>需求全部對上:
mutate(params)就是事件觸發、參數呼叫當下給;結果不進共用快取,state 只屬於這個 hook 實例。預設不自動 retry(query 預設會),對執行來說這才是對的——你不會想讓連結簽發或匯出自己默默重跑三次。免費附送
isPending(順手擋 double-submit)和error。唯一的彆扭是名字:對 read-only 請求寫
useMutation感覺怪。把它讀成useAction就通了——這也是維護者本人的建議用法。 -
一次交付、連狀態都不需要顯示 → 直接在 handler 裡
await,別引入機器。同一個下載,如果連結產生得夠快、你連 spinner 都不打算顯示:
const handleDownload = async () => {const { url } = await api.getReportDownloadLink(reportId);window.open(url);};TanStack Query 的價值在快照管理和狀態機 boilerplate。兩個都用不到時,一個 async function 就是正確答案。第 3 步和第 4 步的差別不在語意——都是執行——只在 UI 需不需要那台狀態機。
不要因為「專案裡到處都是 useQuery」就把執行硬寫成 enabled: false + refetch()。那不是參數沒調好,是拿錯了工具。
最後回到標題的問題。名字會騙人:把 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 實務。
- React Server Components——處理 server state 的另一條路線:不在 client 管快照,把「同步」整段搬回 server 執行。讀懂本篇的快照模型後,正好能看清楚 RSC 想繞開的是哪些問題。