iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Modern Web

現代函式庫與JavaScript的關係系列 第 20

Day 20 | API 呼叫永遠在賽跑 — React Query/TanStack Query 怎麼管理遠端資料狀態

  • 分享至 

  • xImage
  •  

三個提問

  1. 什麼叫「API 呼叫永遠在賽跑」?我怎麼從來沒遇到過?
  2. 為什麼說「遠端資料狀態 ≠ 應用狀態」?不都是 state 嗎?
  3. React Query 到底幫我省掉了什麼,值得多裝一個套件嗎?

一、先看「賽跑」長什麼樣

Day 19 講了 useEffect 的依賴陣列怎麼把人搞進無限循環。今天講另一個更陰險的坑——它不會讓你的程式壞掉,只會讓畫面顯示錯的資料。

情境:一個搜尋框

使用者在搜尋框打字,每打一個字就發一次 API 請求。使用者輸入 react,所以依序發出五個請求:

r → re → rea → reac → react

問題來了:回應抵達的順序,不保證跟發出的順序一樣。

而且有一個很常見的現實因素會讓它更糟:搜尋字串越短,符合的結果越多,伺服器處理越慢

發出的請求 回應時間
r 500 ms
re 400 ms
rea 300 ms
reac 200 ms
react 100 ms

先發出的反而最慢回來。

實測:誰回來就用誰

// 這是最常見的寫法:useEffect 裡 fetch 完直接 setState
const res = await fakeApi(q);
screen = res.result;          // 誰後回來誰覆蓋

實際跑出來:

100 ms 後「react」回來了 → 畫面變成:「react」的搜尋結果
200 ms 後「reac」回來了  → 畫面變成:「reac」的搜尋結果
300 ms 後「rea」回來了   → 畫面變成:「rea」的搜尋結果
400 ms 後「re」回來了    → 畫面變成:「re」的搜尋結果
500 ms 後「r」回來了     → 畫面變成:「r」的搜尋結果

最終畫面:「r」的搜尋結果

使用者明明打完了 react,畫面卻停在 r 的結果。

而且使用者看到的不是錯誤訊息、不是空白,是一個看起來完全正常但內容是錯的畫面——這種 bug 使用者通常不會回報,他只會覺得「這個搜尋很爛」。

為什麼你從來沒遇到過

本機開發時:
  API 在 localhost,每個請求 5~20 ms 回來,
  延遲差異極小 → 幾乎總是「先發先回」,看起來完全正常。

上線之後:
  行動網路、跨國連線、伺服器負載不均,
  延遲從 50 ms 跳到 800 ms,順序開始錯亂。

競態條件(race condition)被稱為「在你的電腦上永遠不會發生的 bug」,就是這個意思。要在本機重現,得自己在 API 裡加隨機延遲。


二、手寫解法要付多少代價

只解決競態這一件事

let latestRequestId = 0;

async function search(q) {
  const myId = ++latestRequestId;      // 每次發請求就拿一個號碼牌
  const res = await fakeApi(q);

  // 回來之後先確認「我還是最新的那個嗎」,不是就直接丟掉
  if (myId !== latestRequestId) return;

  setData(res);
}

白話講這段:用一個一直遞增的號碼 latestRequestId 當「目前最新是第幾號」。每個請求出發前先記下自己的號碼,回來時比對——如果號碼已經不是最新的,代表使用者已經打了新的字,這個結果就過期了,丟掉。

實測結果正確:

100 ms 後「react」回來了 → 採用,畫面更新
200 ms 後「reac」回來了  → 已過期,丟棄
300 ms 後「rea」回來了   → 已過期,丟棄
400 ms 後「re」回來了    → 已過期,丟棄
500 ms 後「r」回來了     → 已過期,丟棄

最終畫面:「react」的搜尋結果  ✅

但這只是開始

一個「完整」的手寫版本,還要處理這些:

function useSearch(query) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);
  const latestId = useRef(0);

  useEffect(() => {
    if (!query) return;

    const myId = ++latestId.current;
    const controller = new AbortController();   // 為了能真的取消請求

    setIsLoading(true);
    setError(null);

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(json => {
        if (myId !== latestId.current) return;   // 競態防護
        setData(json);
      })
      .catch(e => {
        if (e.name === 'AbortError') return;     // 取消不算錯誤
        if (myId !== latestId.current) return;
        setError(e);
      })
      .finally(() => {
        if (myId !== latestId.current) return;
        setIsLoading(false);
      });

    return () => controller.abort();             // 清理
  }, [query]);

  return { data, isLoading, error };
}

這已經 30 行了,而它還沒有:

  • 快取(使用者按 Backspace 退回上一個字,會重新發一次請求)
  • 重複請求去重(兩個元件同時要同一份資料,發兩次)
  • 背景自動更新(資料過期了要不要重抓)
  • 分頁與無限滾動
  • 樂觀更新與回滾

而且這 30 行要在每一個需要抓資料的地方重複一次,或者你自己抽成 hook——那你就是在寫一個小型的 React Query。


三、核心觀念:遠端資料狀態 ≠ 應用狀態

這是整篇最重要的一節。很多人第一次聽到「不要把 API 資料放進 Redux」會覺得莫名其妙,它們不都是 state 嗎?

差別在「誰是唯一真相來源(single source of truth)」。

應用狀態(Client State) 遠端資料狀態(Server State)
唯一真相來源 你的前端記憶體 伺服器
擁有者 伺服器
會被別人改嗎 不會 (其他使用者、你的其他分頁、後台批次)
會過期嗎 不會 會,而且你不會收到通知
需要同步嗎 不用
是同步還是非同步 同步,改了立刻生效 非同步,而且可能失敗
典型例子 側邊欄開關、深色模式、表單草稿、目前選中的分頁 使用者清單、商品資料、訂單狀態

關鍵推論

Redux、Zustand、Context 這類工具,是為「應用狀態」設計的。

它們的設計前提是:你的前端就是唯一真相,改了就是改了。 所以它們提供的能力是「怎麼把狀態放好、怎麼通知元件更新」。

但伺服器資料的難題完全不在這裡。 難題是:

  • 我手上這份資料,現在還是對的嗎
  • 別人改過了嗎?我要怎麼知道?
  • 使用者切到別的分頁再切回來,要不要重抓?
  • 兩個元件同時要同一份資料,要發兩次請求嗎?

把伺服器資料塞進 Zustand,這些問題一個都沒被解決,你只是把它放進一個盒子而已。 而那才是真正花時間的部分。

一句話總結

應用狀態的問題是「怎麼存」,遠端資料狀態的問題是「什麼時候該重新拿」。

Redux 和 Zustand 解決前者,TanStack Query 解決後者。

所以它們不是競爭關係,是互補的。一個專案兩個都用是很正常的。


四、TanStack Query 怎麼處理競態:靠 queryKey

它的做法跟手寫的「號碼牌」不同,而且更根本。

const { data, isLoading, error } = useQuery({
  queryKey: ['search', query],                       // ← 關鍵在這一行
  queryFn: () => fetch(`/api/search?q=${query}`).then(r => r.json()),
});

白話講 queryKey它是這份資料的「身分證」。 query 變了,key 就變了,對 React Query 來說那就是完全不同的一筆查詢,各自有自己的快取格子。

所以競態根本不會發生:

100 ms 後「react」回來了 → 存進快取[react],且是目前的 key,顯示它
200 ms 後「reac」回來了  → 存進快取[reac],非目前 key,只存不顯示
300 ms 後「rea」回來了   → 存進快取[rea],非目前 key,只存不顯示
400 ms 後「re」回來了    → 存進快取[re],非目前 key,只存不顯示
500 ms 後「r」回來了     → 存進快取[r],非目前 key,只存不顯示

最終畫面:「react」的搜尋結果  ✅

注意「只存不顯示」這四個字,這是跟手寫版最大的差別。

手寫的號碼牌做法是把過期的結果丟掉;TanStack Query 是把它存進自己的格子。差別在使用者按 Backspace 的時候:

使用者按 Backspace 退回「reac」時:
  「reac」命中快取,0 ms 直接顯示
→ 不用重發請求,因為那個 key 的結果還在快取裡。

手寫版會重新發一次請求,TanStack Query 直接從快取拿。 這不是它特別聰明,是「以 key 為單位存資料」這個設計自然帶來的結果。


五、三種做法的代價對照

做法 額外程式碼 競態 loading error 快取 Backspace 重用
誰回來就用誰 0 行 ❌ 顯示錯的結果
手寫旗標防護 約 30 行 ✅ 正確 自己寫 自己寫
TanStack Query 約 4 行 ✅ 正確 內建 內建 內建

再加上手寫版完全沒碰的:重複請求去重、視窗重新聚焦時自動更新、背景輪詢、分頁、樂觀更新與回滾。


六、什麼時候不該用

這個系列的慣例,一定要有這一節。四種情況我不會裝它:

a. 純前端狀態

側邊欄開合、深色模式、表單草稿——這些沒有伺服器,硬包成 query 只是繞路。用 useState 或 Zustand。

b. 只讀一次、永遠不變的資料

國家清單、縣市對照表這種。直接在建置時抓下來變成靜態檔(SSG),或啟動時抓一次存起來就好。它不需要「重新取得」的機制,而那正是 TanStack Query 的價值所在。

c. 專案只有一兩個 API 呼叫

多裝一個套件、多學一套心智模型,為了兩個 fetch,不划算。判準是「你有沒有開始在複製貼上那 30 行」——有的話就該裝了。

d. 已經用 Next.js 的 Server Component 抓資料

在伺服器端抓資料的話,競態、loading、快取有一部分由框架處理掉了。混用要想清楚邊界在哪,不要兩套快取打架。


七、今天的判斷標準

看到一段抓資料的程式碼時,問三個問題:

問題 如果答案是「是」
這份資料的真相在伺服器嗎? 它是遠端資料狀態,不要塞進 Redux/Zustand
會不會有兩個請求同時在飛? 你需要競態防護,不然畫面會顯示錯的資料
我有在複製貼上 loading/error 的樣板嗎? 該抽出來了,抽到最後你會做出一個 TanStack Query

還有一個更根本的心態:

抓資料的困難從來不是「怎麼發請求」,而是「怎麼知道手上這份已經過期了」。

fetch 解決前者,只花你一行。後者是所有真實專案的時間都花掉的地方。


明天預告

Day 21 講樂觀更新(Optimistic Update):為什麼「先假裝成功、失敗再回滾」能讓介面感覺快十倍,以及回滾時最容易出錯的那一步。


完整可執行程式碼

存成 day20-race-condition.js,執行 node day20-race-condition.js 可以重跑本文所有實測。純 Node.js 不需要任何套件,也不需要 React——競態條件是非同步本身的問題,跟框架無關。

const sleep = (ms) => new Promise(r => setTimeout(r, ms));

// 模擬 API:關鍵是「不同的查詢字串,回應時間不一樣」
// 白話講:越短的字串通常搜尋結果越多、伺服器越慢,這在真實世界很常見
const fakeApi = async (q) => {
  const delay = { 'r': 500, 're': 400, 'rea': 300, 'reac': 200, 'react': 100 }[q] ?? 150;
  await sleep(delay);
  return { query: q, result: `「${q}」的搜尋結果`, tookMs: delay };
};

const typing = ['r', 're', 'rea', 'reac', 'react'];

async function main() {
  // ── 做法一:誰回來就用誰(錯的)──────────────────────
  let screenA = '(還沒有結果)';
  await Promise.all(typing.map(async (q) => {
    const res = await fakeApi(q);
    screenA = res.result;                     // 誰後回來誰覆蓋
    console.log(`${res.tookMs} ms 後「${q}」回來 → 畫面:${res.result}`);
  }));
  console.log('最終畫面:', screenA);          // → 「r」的結果,錯了

  // ── 做法二:號碼牌防護(手寫解法)────────────────────
  let screenB = '(還沒有結果)';
  let latestRequestId = 0;

  async function searchWithGuard(q) {
    const myId = ++latestRequestId;           // 出發前拿號碼牌
    const res = await fakeApi(q);
    // 白話講:回來後先確認「我還是最新的嗎」,不是就丟掉
    if (myId !== latestRequestId) {
      console.log(`${res.tookMs} ms 後「${q}」回來 → 已過期,丟棄`);
      return;
    }
    screenB = res.result;
    console.log(`${res.tookMs} ms 後「${q}」回來 → 採用`);
  }
  await Promise.all(typing.map(searchWithGuard));
  console.log('最終畫面:', screenB);          // → 「react」的結果,對了

  // ── 做法三:以 key 為單位存(TanStack Query 的思路)──
  const cache = new Map();
  let activeKey = null;

  async function useQuerySim(key) {
    activeKey = key;                          // 目前畫面關心哪個 key
    if (cache.has(key)) {
      console.log(`「${key}」命中快取,0 ms 直接顯示`);
      return cache.get(key);
    }
    const res = await fakeApi(key);
    cache.set(key, res);
    // 白話講:結果填進它自己的格子。畫面只讀 activeKey 那一格,
    //         舊 key 的結果回來也蓋不到畫面
    console.log(`${res.tookMs} ms 後「${key}」回來 → 存進快取[${key}]`
      + (key === activeKey ? ',且是目前 key,顯示它' : ',非目前 key,只存不顯示'));
    return res;
  }
  await Promise.all(typing.map(useQuerySim));
  console.log('最終畫面:', cache.get(activeKey).result);

  // 使用者按 Backspace → 不用重發請求
  await useQuerySim('reac');                  // 命中快取,0 ms
}

main();

參考來源與內容出處說明

延續這個系列的做法,把內容分類標示。

一、有正式出處的部分

內容 出處
queryKey 的語意、快取以 key 為單位、內建的 loading/error/重新取得 TanStack Query 官方文件:https://tanstack.com/query/latest/docs/framework/react/overview
「Server State 與 Client State 是不同問題」這個論點 TanStack Query 官方文件的 Motivation 章節:https://tanstack.com/query/latest/docs/framework/react/overview#motivation
React 官方對 useEffect 抓資料競態的處理建議(ignore flag 與 AbortController) React 官方文件,You Might Not Need an Effect / Fetching data:https://react.dev/reference/react/useEffect#fetching-data-with-effects
AbortController 的行為 MDN:https://developer.mozilla.org/en-US/docs/Web/API/AbortController

二、我實際跑出來的部分

三種做法的輸出、五個請求的抵達順序、Backspace 命中快取,全部由 day20-race-condition.js 實測產生(Node.js v22),可以重跑驗證。

Part 5 的 useQuerySim 是我寫的極簡模擬,不是 TanStack Query 的原始碼。 它只示範「以 key 為單位存放」這一個設計概念,真實實作還包含 stale time、garbage collection、重試策略等,複雜得多。

三、我自己的整理與判斷(沒有外部出處)

  • 「越短的字串伺服器越慢」這個延遲設定,是我為了讓現象明顯而設計的,不是所有 API 都這樣(但它是真實存在的模式)

  • 「競態條件是在你的電腦上永遠不會發生的 bug」這個說法

  • 那張「應用狀態 vs 遠端資料狀態」對照表的分類方式

  • 「應用狀態的問題是怎麼存,遠端資料狀態的問題是什麼時候該重新拿」這句總結

  • 第六節「什麼時候不該用」的四種情況,以及「你有沒有開始複製貼上那 30 行」這個判準

四、行數的注意事項

第二節那個 30 行的手寫版本,是我為了對照而寫的完整版(含 AbortController、競態、loading、error、清理)。實際專案裡很多人只寫其中一部分,所以看到的行數會比這個少——但少掉的部分就是漏掉的防護。

(查閱日期:2026-09-21。程式碼實測於 Node.js v22)


上一篇
Day 19 | 別被無限循環 hooks 牽著走 — 從 dependency array 到 zustand 狀態管理
下一篇
Day 21 | 先假裝成功 — 樂觀更新買到的是 100 毫秒,不是「快十倍」
系列文
現代函式庫與JavaScript的關係22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言