2026/09/21
你的意見會幫助我們持續改善內容。
最近在整理一個既有的股票分析頁面時,我處理了一段累積已久的資料 fetching 技術債。
這個專案原本使用 Zustand 管理股票資訊,同時還在 store 裡自行實作:
單看每一段邏輯,其實都有它當初存在的理由。
但隨著需求與修補逐漸增加,Zustand 已經不只是管理 client state,而是開始承擔一部分 server-state management 的責任。
這次重構的目標,就是把這部分責任重新拆開:
Zustand→ Client State TanStack Query→ Server State而遷移過程中,也順便修掉了幾個原本架構容易產生的資料一致性問題。
原本 store 中會使用一個 Set 避免同一檔股票同時發出重複 request:
const inflightInfo = new Set();並使用:
stock_info: {}作為股票資訊 cache。
在 fetch 前會先判斷:
if (state.stock_info[stockCode]) return;
if (inflightInfo.has(stockCode)) return;
inflightInfo.add(stockCode);也就是已經自行處理:
Cache+Request dedupe除此之外,成功取得資料之後,還會另外存 expiration time,並同步更新目前選中的股票。
整體流程大致變成:
API↓stock_info cache↓select_stock.info↓UI真正開始出問題的地方,就在最後兩層。
原本跟股票資訊有關的資料,同時存在:
stock_info以及:
select_stock.info其中 stock_info 是 cache,但 UI 主要讀取的是 select_stock.info。
API 成功之後,程式必須同時維護兩份 state:
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必須永遠保持同步。
但只要其中一條流程沒有同步完成,就可能產生:
stock_info[stockCode] !== undefined
select_stock.info === null麻煩的是,原本 fetch function 開頭又有:
if (state.stock_info[stockCode]) return;只要 cache 已經存在,就不會重新 request。
結果就可能變成:
UI:沒有資料 Fetcher:Cache 已經有資料,不需要抓 UI:但我讀的不是那份資料最後畫面可能保持空白,而且沒有重新取得資料的契機。
這類問題真正的根源不是 Zustand 本身。
而是:
同一份 Server State 同時存在兩個 rendering source。
使用 Zustand 發 request 本身當然沒有問題。
如果資料生命週期很單純,也完全可以這樣做。
問題出現在開始需要自行處理:
Request dedupeCacheCache expirationLoadingRetryRefetchError recoveryRace conditionRendering source synchronization這時 store 承擔的責任就會快速膨脹。
Legacy code 很常是這樣逐步長出來的:
先把 API response 放進 store↓避免重複 request,所以加入 cache↓又怕 concurrent request,所以加入 inflight flag↓cache 不能永久存在,再加入 expiration↓切換資料可能 race,再加入 current state check↓接著開始處理 retry、error recovery...沒有哪一步特別荒謬。
甚至每一步單獨看都合理。
真正麻煩的是,最後整套系統已經開始自行實作 query management。
這次我把股票資訊抽成獨立 query。
先定義 query key:
export const stockInfoKeys = {
all: ['stockInfo'] as const,
detail: (stockCode: string, country: string) =>
[...stockInfoKeys.all, stockCode, country] as const,
};股票資訊的 identity 因此變成:
['stockInfo', stockCode, country]接著使用:
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:
return {
info: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
refetch: query.refetch,
};新的資料流變成:
API↓Query Cache↓query.data↓UI這次重構最重要的改變,並不是 syntax 變成 useQuery。
而是:
Cache 與 rendering source 重新變成同一份資料。
不再需要額外將 cache 複製到 select_stock.info。
少掉一層同步,也就少掉一整類 consistency bug。
整理舊邏輯時還發現另一個風險。
原本在 API status 成功後,會直接使用:
responseData.data.data如果後端 response shape 改變,或者某些情況缺少資料,最後可能得到:
undefined但 request 本身仍然已經成功。
如果這筆空資料接著被當成有效結果保存,UI 可能只會得到空值。
更麻煩的是,如果 cache 因此被認定為存在,後續甚至可能不再重新 fetch。
所以新的 fetchStockInfo 會額外驗證 payload:
const info = envelope.data?.data;
if (!info || size(info) === 0) {
throw new Error(
`WebStockInfo 回應沒有資料(${stockCode} / ${country})`
);
}這裡我把兩件事分開:
Request 成功以及:
取得有效資料這兩者並不等價。
尤其這份資料是 UI 的主要 rendering source 時,空 payload 與錯誤 payload 不應該直接當成有效 cache。
這個專案原本已經有全域 error handling:
QueryCache.onError↓ResponseErrorHandler↓Global Toast但股票資訊列本身也有 inline error UI。
例如:
載入失敗,點此重試如果所有錯誤都交給全域處理,很容易發生:
資訊列:載入失敗 Global Toast:Network Error同一件事被顯示兩次。
所以這次我也順便把 error 分成幾種類型。
例如:
這類錯誤交給 query 自己 retry,最後由資訊列呈現 inline error。
例如 API 明確回:
{
"status": "error",
"error": {
"code": "E400xxx"
}
}這類錯誤則保留既有全域 ResponseErrorHandler。
HTTP request 本身成功,但 response 為空或格式不符合預期。
這種情況主動 throw,讓 TanStack Query 將這次結果視為 failed query。
拆開之後,每種 error 的 UI 與 recovery strategy 都比較明確。
TanStack Query 可以使用:
placeholderData: keepPreviousData在 query key 改變時暫時顯示上一筆資料。
對 pagination 或部分 list UI 來說,這可以降低 loading flicker。
但這個股票資訊列我刻意沒有使用。
因為資訊列會直接跟目前股票名稱一起顯示。
例如目前是:
台泥 1101股價:XX接著使用者切換成:
台積電 2330如果保留 previous data,就有可能短暫看到:
台積電 2330股價:台泥的資料畫面可能比較「順」,資料卻是錯的。
尤其金融資訊這種情境,我認為:
短暫沒有資料,比短暫顯示錯誤資料更合理。
所以沒有使用 keepPreviousData 並不是漏加一個 optimization。
而是刻意的 UX decision。
使用這類功能前,我覺得應該先問:
Previous data 對目前這個 entity 而言,是「舊資料」,還是已經變成「錯誤資料」?
兩者差很多。
這個專案全域的 TanStack Query 設定是:
retry: false我沒有因為這次需求去修改全域設定。
而是只針對股票資訊覆寫:
retry: 2,
retryDelay: attempt =>
Math.min(1000 * 2 ** attempt, 8000),原因是這排股票資訊是畫面的主要資料來源之一。
如果第一次 request 因為:
失敗,一次錯誤就永久停在 error state 並不是很好的 recovery strategy。
但其他 API 不一定有相同需求。
所以這裡選擇 query-level override,而不是改變所有 query 的行為。
這也是我很喜歡 TanStack Query 的地方之一:
Cache 與 recovery policy 可以依照每種資料的特性決定,而不是全部綁死在同一套 store 行為。
專案全域另外關閉了:
refetchOnWindowFocus: false這支 query 則單獨開啟。
目的不是讓使用者每次切換 tab 都重新 request。
成功資料在 staleTime 內仍然是 fresh,不會因此一直 refetch。
真正有幫助的是失敗狀態。
例如:
第一次 request 失敗↓retry 2 次後仍然失敗↓使用者切去其他分頁↓稍後再切回來如果當時 API 或網路已經恢復,window focus 就提供另一個 recovery point。
相比永久停留在 error state,這種行為更符合這份資料的使用情境。
舊邏輯中,股票資訊 fetch 主要依賴:
stockCodecountry 則透過:
resolveStockCountry(stockCode)重新推論。
但股票 pool 本身其實可能已經帶有 country。
而且不同市場的股票代碼規則並不保證永遠能單靠字串格式正確判斷。
所以新的 query 優先使用:
stock.country沒有時才 fallback 至:
resolveStockCountry(code)Query key 也因此包含:
['stockInfo', stockCode, country]因為這份 Server State 真正的 identity 並不是只有:
stockCode而是:
stockCode + country這也可以避免不同市場資料意外共用同一份 cache。
這次重構並不是「把 Zustand 換成 TanStack Query」。
這兩個 library 解決的問題本來就不完全一樣。
目前選中的股票、跨 component UI state,以及其他 client-side interaction state,依然可以留在 Zustand。
真正被搬走的是這一段:
API Data+Cache+Request lifecycle+Retry / Refetch所以最後的 boundary 比較接近:
Zustand→ Client State TanStack Query→ Server State這次 migration 的價值不是證明某個 library 比另一個 library 好。
而是重新整理 state ownership。
這次修改表面上看只是:
Zustand fetch→TanStack Query但實際處理掉的是:
雙資料來源手動 request dedupe手動 cache lifecycle錯誤 recovery 不完整空 payload 被視為成功country identity 不完整Server State 與 Client State 混在一起Legacy code 很少是某一天突然被某個人一次寫成一團。
更常見的是每一次需求都加上一點合理的修補,最後累積成一個 responsibility 過多的 abstraction。
所以這次真正想做的,不是單純換 library。
而是把原本混在一起的責任重新拆開:
Client StateServer StateError HandlingCache PolicyRecovery Strategy當每一層都有比較明確的 owner,後續要修改時需要理解的隱含規則自然也會變少。
對我來說,這才是處理技術債真正有價值的地方。
不是把舊 code 寫成新 syntax。
而是讓下一個維護的人,不需要重新理解同一套手刻的 cache system。