當 Zustand 開始長得像半套 TanStack Query:一次 Legacy Server State 重構

2026/09/21

當既有 Zustand store 開始同時負責 API cache、request dedupe、expiration、error handling 與 rendering state,同步問題也跟著出現。這篇記錄一次真實的 Legacy 重構:從找出 Server State 的責任邊界,到遷移至 TanStack Query,並改善資料一致性、錯誤恢復與 query identity。
Abstract state management banner

最近在整理一個既有的股票分析頁面時,我處理了一段累積已久的資料 fetching 技術債。

這個專案原本使用 Zustand 管理股票資訊,同時還在 store 裡自行實作:

  • API response cache
  • request dedupe
  • cache expiration
  • loading state
  • error handling
  • selected stock 與 cache 的同步

單看每一段邏輯,其實都有它當初存在的理由。

但隨著需求與修補逐漸增加,Zustand 已經不只是管理 client state,而是開始承擔一部分 server-state management 的責任。

這次重構的目標,就是把這部分責任重新拆開:

text
Zustand→ Client State TanStack Query→ Server State

而遷移過程中,也順便修掉了幾個原本架構容易產生的資料一致性問題。

舊架構:在 Zustand 裡自行管理 Server State

原本 store 中會使用一個 Set 避免同一檔股票同時發出重複 request:

ts
const inflightInfo = new Set();

並使用:

ts
stock_info: {}

作為股票資訊 cache。

在 fetch 前會先判斷:

ts
if (state.stock_info[stockCode]) return;

if (inflightInfo.has(stockCode)) return;

inflightInfo.add(stockCode);

也就是已經自行處理:

text
Cache+Request dedupe

除此之外,成功取得資料之後,還會另外存 expiration time,並同步更新目前選中的股票。

整體流程大致變成:

text
API↓stock_info cache↓select_stock.info↓UI

真正開始出問題的地方,就在最後兩層。

同一份 Server State 被複製成兩份

原本跟股票資訊有關的資料,同時存在:

ts
stock_info

以及:

ts
select_stock.info

其中 stock_info 是 cache,但 UI 主要讀取的是 select_stock.info。

API 成功之後,程式必須同時維護兩份 state:

ts
const info = {
  data: responseData.data.data,
  time: moment().add(cacheExpired, 'minutes').format(),
};

const updatedStock = isStillCurrent
  ? { ...currentState.select_stock, info }
  : currentState.select_stock;

const stockInfo = {
  ...currentState.stock_info,
  [stockCode]: info,
};

set({
  select_stock: updatedStock,
  stock_info: stockInfo,
});

這代表系統存在一個隱含條件:

stock_info 與 select_stock.info 必須永遠保持同步。

但只要其中一條流程沒有同步完成,就可能產生:

ts
stock_info[stockCode] !== undefined
select_stock.info === null

麻煩的是,原本 fetch function 開頭又有:

ts
if (state.stock_info[stockCode]) return;

只要 cache 已經存在,就不會重新 request。

結果就可能變成:

text
UI:沒有資料 Fetcher:Cache 已經有資料,不需要抓 UI:但我讀的不是那份資料

最後畫面可能保持空白,而且沒有重新取得資料的契機。

這類問題真正的根源不是 Zustand 本身。

而是:

同一份 Server State 同時存在兩個 rendering source。

問題不是「不能用 Zustand Fetch API」

使用 Zustand 發 request 本身當然沒有問題。

如果資料生命週期很單純,也完全可以這樣做。

問題出現在開始需要自行處理:

text
Request dedupeCacheCache expirationLoadingRetryRefetchError recoveryRace conditionRendering source synchronization

這時 store 承擔的責任就會快速膨脹。

Legacy code 很常是這樣逐步長出來的:

text
先把 API response 放進 store↓避免重複 request,所以加入 cache↓又怕 concurrent request,所以加入 inflight flag↓cache 不能永久存在,再加入 expiration↓切換資料可能 race,再加入 current state check↓接著開始處理 retry、error recovery...

沒有哪一步特別荒謬。

甚至每一步單獨看都合理。

真正麻煩的是,最後整套系統已經開始自行實作 query management。

遷移到 TanStack Query

這次我把股票資訊抽成獨立 query。

先定義 query key:

ts
export const stockInfoKeys = {
  all: ['stockInfo'] as const,

  detail: (stockCode: string, country: string) =>
    [...stockInfoKeys.all, stockCode, country] as const,
};

股票資訊的 identity 因此變成:

ts
['stockInfo', stockCode, country]

接著使用:

ts
const query = useQuery({
  queryKey: stockInfoKeys.detail(code, resolvedCountry),

  queryFn: () =>
    fetchStockInfo(code, resolvedCountry),

  enabled: !!code,

  retry: 2,

  retryDelay: attempt =>
    Math.min(1000 * 2 ** attempt, 8000),

  refetchOnWindowFocus: true,
});

最後 UI 直接使用 query data:

ts
return {
  info: query.data ?? null,
  isLoading: query.isLoading,
  isError: query.isError,
  refetch: query.refetch,
};

新的資料流變成:

text
API↓Query Cache↓query.data↓UI

這次重構最重要的改變,並不是 syntax 變成 useQuery。

而是:

Cache 與 rendering source 重新變成同一份資料。

不再需要額外將 cache 複製到 select_stock.info。

少掉一層同步,也就少掉一整類 consistency bug。

API Request 成功,不代表資料有效

整理舊邏輯時還發現另一個風險。

原本在 API status 成功後,會直接使用:

ts
responseData.data.data

如果後端 response shape 改變,或者某些情況缺少資料,最後可能得到:

ts
undefined

但 request 本身仍然已經成功。

如果這筆空資料接著被當成有效結果保存,UI 可能只會得到空值。

更麻煩的是,如果 cache 因此被認定為存在,後續甚至可能不再重新 fetch。

所以新的 fetchStockInfo 會額外驗證 payload:

ts
const info = envelope.data?.data;

if (!info || size(info) === 0) {
  throw new Error(
    `WebStockInfo 回應沒有資料(${stockCode} / ${country})`
  );
}

這裡我把兩件事分開:

text
Request 成功

以及:

text
取得有效資料

這兩者並不等價。

尤其這份資料是 UI 的主要 rendering source 時,空 payload 與錯誤 payload 不應該直接當成有效 cache。

Error 其實也有不同類型

這個專案原本已經有全域 error handling:

text
QueryCache.onError↓ResponseErrorHandler↓Global Toast

但股票資訊列本身也有 inline error UI。

例如:

text
載入失敗,點此重試

如果所有錯誤都交給全域處理,很容易發生:

text
資訊列:載入失敗 Global Toast:Network Error

同一件事被顯示兩次。

所以這次我也順便把 error 分成幾種類型。

Transport Error

例如:

  • Timeout
  • Offline
  • Network error
  • Request 被中斷

這類錯誤交給 query 自己 retry,最後由資訊列呈現 inline error。

Business Error

例如 API 明確回:

json
{
  "status": "error",
  "error": {
    "code": "E400xxx"
  }
}

這類錯誤則保留既有全域 ResponseErrorHandler。

Invalid Payload

HTTP request 本身成功,但 response 為空或格式不符合預期。

這種情況主動 throw,讓 TanStack Query 將這次結果視為 failed query。

拆開之後,每種 error 的 UI 與 recovery strategy 都比較明確。

為什麼沒有使用 keepPreviousData?

TanStack Query 可以使用:

ts
placeholderData: keepPreviousData

在 query key 改變時暫時顯示上一筆資料。

對 pagination 或部分 list UI 來說,這可以降低 loading flicker。

但這個股票資訊列我刻意沒有使用。

因為資訊列會直接跟目前股票名稱一起顯示。

例如目前是:

text
台泥 1101股價:XX

接著使用者切換成:

text
台積電 2330

如果保留 previous data,就有可能短暫看到:

text
台積電 2330股價:台泥的資料

畫面可能比較「順」,資料卻是錯的。

尤其金融資訊這種情境,我認為:

短暫沒有資料,比短暫顯示錯誤資料更合理。

所以沒有使用 keepPreviousData 並不是漏加一個 optimization。

而是刻意的 UX decision。

使用這類功能前,我覺得應該先問:

Previous data 對目前這個 entity 而言,是「舊資料」,還是已經變成「錯誤資料」?

兩者差很多。

Retry Policy 應該依資料特性決定

這個專案全域的 TanStack Query 設定是:

ts
retry: false

我沒有因為這次需求去修改全域設定。

而是只針對股票資訊覆寫:

ts
retry: 2,

retryDelay: attempt =>
  Math.min(1000 * 2 ** attempt, 8000),

原因是這排股票資訊是畫面的主要資料來源之一。

如果第一次 request 因為:

  • 冷連線
  • 暫時性 5xx
  • 網路瞬間不穩
  • Request 被意外中斷

失敗,一次錯誤就永久停在 error state 並不是很好的 recovery strategy。

但其他 API 不一定有相同需求。

所以這裡選擇 query-level override,而不是改變所有 query 的行為。

這也是我很喜歡 TanStack Query 的地方之一:

Cache 與 recovery policy 可以依照每種資料的特性決定,而不是全部綁死在同一套 store 行為。

refetchOnWindowFocus 也是 Recovery Strategy

專案全域另外關閉了:

ts
refetchOnWindowFocus: false

這支 query 則單獨開啟。

目的不是讓使用者每次切換 tab 都重新 request。

成功資料在 staleTime 內仍然是 fresh,不會因此一直 refetch。

真正有幫助的是失敗狀態。

例如:

text
第一次 request 失敗↓retry 2 次後仍然失敗↓使用者切去其他分頁↓稍後再切回來

如果當時 API 或網路已經恢復,window focus 就提供另一個 recovery point。

相比永久停留在 error state,這種行為更符合這份資料的使用情境。

Country 也應該是資料 Identity 的一部分

舊邏輯中,股票資訊 fetch 主要依賴:

ts
stockCode

country 則透過:

ts
resolveStockCountry(stockCode)

重新推論。

但股票 pool 本身其實可能已經帶有 country。

而且不同市場的股票代碼規則並不保證永遠能單靠字串格式正確判斷。

所以新的 query 優先使用:

ts
stock.country

沒有時才 fallback 至:

ts
resolveStockCountry(code)

Query key 也因此包含:

ts
['stockInfo', stockCode, country]

因為這份 Server State 真正的 identity 並不是只有:

text
stockCode

而是:

text
stockCode + country

這也可以避免不同市場資料意外共用同一份 cache。

Zustand 並沒有因此被淘汰

這次重構並不是「把 Zustand 換成 TanStack Query」。

這兩個 library 解決的問題本來就不完全一樣。

目前選中的股票、跨 component UI state,以及其他 client-side interaction state,依然可以留在 Zustand。

真正被搬走的是這一段:

text
API Data+Cache+Request lifecycle+Retry / Refetch

所以最後的 boundary 比較接近:

text
Zustand→ Client State TanStack Query→ Server State

這次 migration 的價值不是證明某個 library 比另一個 library 好。

而是重新整理 state ownership。

重構 Legacy Code 時,我更在意責任邊界

這次修改表面上看只是:

text
Zustand fetch→TanStack Query

但實際處理掉的是:

text
雙資料來源手動 request dedupe手動 cache lifecycle錯誤 recovery 不完整空 payload 被視為成功country identity 不完整Server State 與 Client State 混在一起

Legacy code 很少是某一天突然被某個人一次寫成一團。

更常見的是每一次需求都加上一點合理的修補,最後累積成一個 responsibility 過多的 abstraction。

所以這次真正想做的,不是單純換 library。

而是把原本混在一起的責任重新拆開:

text
Client StateServer StateError HandlingCache PolicyRecovery Strategy

當每一層都有比較明確的 owner,後續要修改時需要理解的隱含規則自然也會變少。

對我來說,這才是處理技術債真正有價值的地方。

不是把舊 code 寫成新 syntax。

而是讓下一個維護的人,不需要重新理解同一套手刻的 cache system。


這篇文章有幫助嗎?