queryKey 設計的鍋,還是我快取失效的寫法出了問題?invalidateQueries 為什麼有時有效有時沒效? 「只要 mutation 結束就刷新所有統計」這句話隱藏的坑在哪?Day 15 講 async/await 的錯誤處理時,重點是「你的程式碼怎麼讀寫 API 回應」。今天要講的是「框架替你讀寫 API 回應」時會遇到的問題。
TanStack Query(前身叫 React Query)解決的不是「怎麼 fetch」,而是「拿到資料之後怎麼放」。
而 queryKey 就是「怎麼放」這件事的鑰匙。
真實場景是這樣的(事件管理系統裡):
使用者操作:
1. 打開儀表板,看到「已售票券:120」
2. 選了篩選器「只看已完成的事件」
3. 點搜尋
4. 頁面轉圈,後端回來新數字「已售票券:45」
5. 轉圈結束,120 沒變
時序(Chrome DevTools Network 分頁看):
第一次 GET /api/stats → Response: {"sold": 120}
使用者改篩選
第二次 GET /api/stats?eventStatus=completed → Response: {"sold": 45}
結論:Response 確實是 45,但畫面上還是 120
當時最大膽的假設是「後端回來的東西被什麼地方吃掉了」,所以追了半天 middleware、interceptor、response transformer…… 都沒問題。
再早一週的情況:
使用者操作:
1. 從某個票券項目修改了「通路」(channel)
2. 按保存
3. 卡片上的「通路銷售額」應該重算,還是舊數字
後來發現:
- mutation 那個 endpoint 確實改成功了(資料庫有新數據)
- GET 一下新的統計數字,也確實變了
- 但頁面上的卡片「不知道」有新數據可以拿
都是「後端已經有新數據」但「前端的快取記憶體還留著舊數據」,而且前端沒有意識到「已經應該拋棄舊快取了」。
代碼長這樣:
// ❌ 猜測一時代的代碼
export function StatsCard() {
const [filters, setFilters] = useState({ eventStatus: 'all' });
const { data: stats } = useQuery({
queryKey: ['stats'], // ← 看這行!
queryFn: () => fetchStats(filters),
});
return (
<Card>
<span>已售:{stats?.sold}</span>
<Select onChange={(e) => setFilters({ ...filters, eventStatus: e.target.value })} />
</Card>
);
}
我花了一整個下午試著搞清楚「useQuery 是不是在 effect 清理有問題」,結果 DevTools 的 Query 分頁裡根本沒有重複的 query 實例,就一個。
我以為只要把 component 卸載再掛載,快取就會刷新,所以試過:
// ❌ 猜測二時代的代碼
<StatsCard key={filters.eventStatus} /> {/* 每次 filter 改都卸載重掛 */}
確實能「解決」卡片的問題(卸載重掛讓 query 重新跑),但這根本不是解法,只是「燒毀村莊救火」的招式。用了兩週後開始有其他地方組件狀態丟失。
這個招式看起來有效,但埋的雷更大。
queryClient 被 provider 包兩層,互相看不到// ❌ 猜測三時代的 debug
console.log('StatsCard 的 queryClient:', useQueryClient());
console.log('FilterBar 的 queryClient:', useQueryClient());
console.log('兩個是同一個嗎?', ...);
花了一個下午確認它們是同一個。
白費力氣。
queryKey 不是「便利貼」,是 「這筆資料是怎麼問出來的」的完整記錄。
// 記得 Day 23 講「引用相等性」嗎?
const queryKey1 = ['stats'];
const queryKey2 = ['stats'];
queryKey1 === queryKey2 // false!陣列每次都是新的
// TanStack Query 的做法是深度比較
useQuery({
queryKey: ['stats'], // ← 如果你每次都寫 ['stats'],它看的是內容,不是物件身份
// ...
})
白話解釋:
我那時的 queryKey: ['stats'] 從頭到尾都是一樣的,所以快取永遠在說「我有答案」。而我改 filter 時,查詢參數送到後端了,但 queryKey 沒改,快取系統根本沒有被通知「有新問題要問」。
// ❌ 當時的代碼
const { data: stats } = useQuery({
queryKey: ['stats'], // ← 永遠是 ['stats'],filter 改了也不會變
queryFn: () => fetchStats(filters), // ← 這裡 filters 有變
});
時序跡象:
filterStatus = 'all' 時,queryKey 是 ['stats'],fetch /stats?status=all 回 120filterStatus = 'completed'
fetchStats(filters) 送出 /stats?status=completed
['stats'] ← 快取系統不知道是新問題queryKey 沒變 = 快取系統根本沒被通知有新問題
// ❌ 當時的代碼
// mutation 那邊:
const mutation = useMutation({
mutationFn: updateChannel,
onSuccess: () => {
// ← 這裡沒有說「我改完了,統計卡片要重新算」
queryClient.invalidateQueries({ queryKey: ['stats'] }); // ← 寫過,但...
}
});
// stats 卡片那邊:
const { data: stats } = useQuery({
queryKey: ['stats'], // ← 你的 key 是簡簡單單一個 'stats'
queryFn: fetchStats,
});
乍一看我有 invalidateQueries,但問題在「我改的是哪一通路的資料」,statsi 應該也要只重新拿那個通路的資料。這時候 queryKey 應該包含通路訊息:
// 應該是這樣
queryKey: ['stats', { channelId: 'channel_123' }]
但我的 queryKey 是 ['stats'],所以:
invalidateQueries 說「把『stats』相關的都丟掉」// ✅ 安全的做法
function useStatsQuery(filters) {
return useQuery({
queryKey: ['stats', filters], // ← filters 物件做為 key 的一部分
queryFn: () => fetchStats(filters),
// 如果你的 filters 物件結構經常變化(新增/刪除欄位)
// 可以更嚴格地只挑選會影響結果的欄位:
// queryKey: ['stats', { eventStatus: filters.eventStatus, channelId: filters.channelId }],
});
}
// 使用
const [filters, setFilters] = useState({ eventStatus: 'all' });
const { data: stats } = useStatsQuery(filters);
// filters 變了 → queryKey 自動變 → 快取自動失效 → 自動 refetch
// ✅ onSuccess 時指定準確的 key pattern
const mutation = useMutation({
mutationFn: (channelData) => updateChannel(channelData.id, channelData),
onSuccess: (response, variables) => {
// 只失效這個通路相關的統計
queryClient.invalidateQueries({
queryKey: ['stats', { channelId: variables.id }],
});
// 或用 exact: false 配合 prefix 做粗略失效(謹慎用)
// queryClient.invalidateQueries({ queryKey: ['stats'], exact: false });
},
});
// ✅ 用陣列層級表達查詢的層次
const queryKeys = {
all: ['stats'],
byEvent: (eventId) => [...queryKeys.all, 'event', eventId],
byChannel: (channelId) => [...queryKeys.all, 'channel', channelId],
detailed: (filters) => [...queryKeys.all, 'detailed', filters],
};
// 使用
useQuery({
queryKey: queryKeys.byEvent(eventId),
queryFn: () => fetchStatsByEvent(eventId),
});
// mutation 時
queryClient.invalidateQueries({ queryKey: queryKeys.all, exact: false });
// 這樣會同時失效 byEvent、byChannel、detailed 下的所有快取
假設使用者在搜尋框打「高級票」,頁面要送兩個 request:
時刻 0ms:使用者在搜尋框打完「高級票」
時刻 10ms:前端送 GET /tickets?search=高級票 (Request A)
時刻 50ms:使用者改主意,再打「普通票」
時刻 60ms:前端送 GET /tickets?search=普通票 (Request B)
時刻 150ms:Request B 回來 Response B:[普通票 1, 普通票 2]
時刻 200ms:Request A 才回來 Response A:[高級票 1, 高級票 2]
結論:畫面會顯示「高級票」,這是 Response A,而不是最新的 Request B。
// ❌ 用 state 追蹤搜尋詞,但時序亂
function SearchTickets() {
const [search, setSearch] = useState('');
const [displaySearch, setDisplaySearch] = useState('');
const { data: tickets } = useQuery({
queryKey: ['tickets', displaySearch],
queryFn: () => fetchTickets(displaySearch),
enabled: displaySearch !== '', // ← 空字串不查
});
const handleSearch = (value) => {
setSearch(value); // ← 這個改了
// 但什麼時候 setDisplaySearch?
// debounce 多久?
};
return <input onChange={(e) => handleSearch(e.target.value)} />;
}
request deduping + queryKey 的解法// ✅ 讓 TanStack Query 幫你處理
function SearchTickets() {
const [search, setSearch] = useState('');
const { data: tickets, isPending } = useQuery({
queryKey: ['tickets', search], // ← search 是唯一的真相
queryFn: () => fetchTickets(search),
enabled: search.length > 0,
staleTime: 5 * 60 * 1000, // 5 分鐘內同個搜尋不重新 fetch
});
return (
<>
<input
onChange={(e) => setSearch(e.target.value)}
placeholder="搜尋票券"
/>
{isPending && <Spinner />}
<TicketList tickets={tickets} />
</>
);
}
怎麼樣避免競態條件:
TanStack Query 的預設行為是 「最後一個 queryKey 的 response 用」。當 Response A 晚到時,queryClient 看到:
目前 queryKey 是什麼?['tickets', '普通票']
Response A 的 queryKey 是?['tickets', '高級票']
// 不匹配!Response A 不會被當成目前這筆查詢的答案
// 把它存起來,但不渲染
而使用者能感受到的是:
檔名 day25-query-key-and-cache.js。包含四段實測,可以直接在 Node.js 跑:
/**
* day25-query-key-and-cache.js
*
* TanStack Query 的 queryKey 與快取失效
* 搭配 iThome 鐵人賽 2026 Day 25
*
* 執行方式:node day25-query-key-and-cache.js
* 環境:Node.js v18 以上
*
* 依賴:Part C 會 require('@tanstack/react-query')
* 沒有裝的話會跳過。要跑完整版:npm install @tanstack/react-query
*
* 五個 Part
* Part A queryKey 的本質:為什麼陣列的深度比較是必須的
* Part B bug 重現一:filter 改了但卡片沒變
* Part C bug 重現二:mutation 後快取沒失效
* Part D pattern 示範:怎麼用 queryKey 避免 bug
* Part E 競態條件:搜尋詞連打三次的時序模擬
*/
'use strict'
// 共用工具
function 分隔線(title) {
console.log('\n' + '='.repeat(70))
console.log(title)
console.log('='.repeat(70))
}
function 小標(title) {
console.log('\n--- ' + title + ' ---')
}
// ============================================================
// Part A:queryKey 的本質
// ============================================================
function partA() {
分隔線('Part A:queryKey 為什麼是「這筆資料怎麼問出來的」的完整記錄')
// 示範一:陣列淺比較 vs 深比較
console.log('')
console.log('陣列的身份 vs 內容:')
const arr1 = ['stats', { eventId: 1 }]
const arr2 = ['stats', { eventId: 1 }]
console.log(`arr1 === arr2?${arr1 === arr2} ← 身份不同(不同陣列物件)`)
console.log(`JSON.stringify(arr1) === JSON.stringify(arr2)?${JSON.stringify(arr1) === JSON.stringify(arr2)} ← 內容相同(深度比較)`)
// 示範二:TanStack Query 用的就是內容比較
console.log('')
console.log('在 TanStack Query 的快取裡:')
console.log(' queryKey 身份無關,內容決定快取鍵')
console.log(' queryKey: [\'stats\', { eventId: 1 }]')
console.log(' queryKey: [\'stats\', { eventId: 1 }]')
console.log(' ↑ 這兩個會指向同一筆快取')
// 示範三:結構改變 = 新快取
console.log('')
console.log('結構改變會觸發新快取:')
const statKey1 = ['stats', { eventId: 1 }]
const statKey2 = ['stats', { eventId: 1, status: 'completed' }]
console.log(` ${JSON.stringify(statKey1)} ← 快取 1`)
console.log(` ${JSON.stringify(statKey2)} ← 快取 2(完全不同的鍵)`)
console.log('')
console.log('白話:queryKey 改變 = 快取系統發現「這是新問題」= 觸發新 fetch')
}
// ============================================================
// Part B:bug 重現一 - filter 改了卡片卻沒變
// ============================================================
class MockQueryClient {
constructor() {
this.cache = new Map()
this.queries = new Map()
}
// 簡化的 useQuery
query(key, fn) {
const keyStr = JSON.stringify(key)
// 如果快取命中,直接回傳
if (this.cache.has(keyStr)) {
console.log(` [快取] 快取命中 ${keyStr}`)
return this.cache.get(keyStr)
}
// 沒有快取,執行 fn 並存入快取
console.log(` [fetch] 執行 queryFn...`)
const result = fn()
this.cache.set(keyStr, result)
console.log(` [完成] 存入快取 ${keyStr} = ${JSON.stringify(result)}`)
return result
}
// 失效快取
invalidateQueries(pattern) {
const keyStr = JSON.stringify(pattern.queryKey)
if (this.cache.has(keyStr)) {
this.cache.delete(keyStr)
console.log(` [失效] 刪除快取 ${keyStr}`)
}
}
}
function partB() {
分隔線('Part B:Bug 重現一 —— filter 改了但卡片沒變')
小標('❌ 有 bug 的寫法')
console.log('代碼:')
console.log(' useQuery({')
console.log(" queryKey: ['stats'], ← 永遠都是這個!")
console.log(' queryFn: () => fetchStats(filters),')
console.log(' })')
const client = new MockQueryClient()
console.log('\n時序:')
console.log(' 1️⃣ 初始狀態,filter = { status: "all" }')
let result1 = client.query(['stats'], () => {
return { sold: 120, title: 'all events' }
})
console.log(` 畫面顯示:${JSON.stringify(result1)}`)
console.log('\n 2️⃣ 使用者改 filter 為 { status: "completed" }')
console.log(' fetchStats(新 filter) 被呼叫了')
let result2 = client.query(['stats'], () => {
return { sold: 45, title: 'completed events' }
})
console.log(` 快取回傳舊數據:${JSON.stringify(result1)}`)
console.log(` 後台 fetch 其實回來 ${JSON.stringify(result2)} 但被忽略了`)
console.log(` 畫面還是顯示:${JSON.stringify(result1)} ← BUG!`)
小標('✅ 修法:把 filter 放進 queryKey')
console.log('代碼:')
console.log(' useQuery({')
console.log(" queryKey: ['stats', filters], ← filters 改了,key 也改")
console.log(' queryFn: () => fetchStats(filters),')
console.log(' })')
const client2 = new MockQueryClient()
console.log('\n時序(修復後):')
console.log(' 1️⃣ 初始,filter = { status: "all" }')
result1 = client2.query(['stats', { status: 'all' }], () => {
return { sold: 120 }
})
console.log(` 畫面:${JSON.stringify(result1)}`)
console.log('\n 2️⃣ 使用者改 filter 為 { status: "completed" }')
console.log(' queryKey 從 ["stats", {status:"all"}] 變成 ["stats", {status:"completed"}]')
result2 = client2.query(['stats', { status: 'completed' }], () => {
return { sold: 45 }
})
console.log(` 快取未命中(key 不同!),執行新 fetch`)
console.log(` 畫面:${JSON.stringify(result2)} ← 正確!`)
}
// ============================================================
// Part C:bug 重現二 - mutation 後快取沒失效
// ============================================================
function partC() {
分隔線('Part C:Bug 重現二 —— mutation 之後卡片該改沒改')
小標('❌ 有 bug 的寫法')
console.log('mutation 成功時:')
console.log(' queryClient.invalidateQueries({ queryKey: [\'stats\'] })')
console.log('stats query 定義:')
console.log(' queryKey: [\'stats\']')
console.log('')
console.log('但實際上:')
console.log(' - 你改的是「通路 123 的銷售額」')
console.log(' - stats 原本只看全體銷售額')
console.log(' - 有時候 stats 需要分通路看')
console.log(' - 同一個 queryKey [\'stats\'] 被多個 component 共享')
console.log(' - 其中某個 component 改完資料,全部 component 的快取都被失效了')
console.log(' - 而不是「只失效相關的那一筆」')
小標('✅ 修法一:mutation 時明確指定 queryKey')
console.log('mutation 成功時:')
console.log(' queryClient.invalidateQueries({')
console.log(' queryKey: [\'stats\', { channelId: 改掉的通路 ID }]')
console.log(' })')
console.log('stats query 定義:')
console.log(' useQuery({')
console.log(' queryKey: [\'stats\', { channelId: 當前通路 }]')
console.log(' ...')
console.log(' })')
console.log('')
console.log('結果:只有這個通路相關的快取被失效,其他通路的快取保留')
小標('✅ 修法二:分層 key 設計')
console.log('定義:')
console.log(' const statKeys = {')
console.log(" all: ['stats'],")
console.log(" byChannel: (id) => [...statKeys.all, 'channel', id],")
console.log(' }')
console.log('')
console.log('mutation 時:')
console.log(' queryClient.invalidateQueries({')
console.log(' queryKey: statKeys.byChannel(改掉的通路 ID)')
console.log(' })')
console.log('')
console.log('或粗略失效:')
console.log(' queryClient.invalidateQueries({')
console.log(' queryKey: statKeys.all,')
console.log(' exact: false ← 把 all 下的所有子層都失效')
console.log(' })')
}
// ============================================================
// Part D:queryKey pattern 示範
// ============================================================
function partD() {
分隔線('Part D:安全的 queryKey 設計模式')
小標('模式一:把所有會影響結果的參數都放進 key')
console.log(`
const queryKeys = {
all: ['tickets'],
bySearch: (search) => [...queryKeys.all, 'search', search],
byFilter: (filter) => [...queryKeys.all, 'filter', filter],
};
// 使用
useQuery({
queryKey: queryKeys.bySearch(searchTerm),
queryFn: () => fetchTickets(searchTerm),
});
// searchTerm 變了 → queryKey 自動變 → 快取失效 → 自動 refetch
`)
小標('模式二:分層結構,方便失效')
console.log(`
const eventKeys = {
all: ['events'],
lists: () => [...eventKeys.all, 'list'],
list: (filters) => [...eventKeys.lists(), filters],
details: () => [...eventKeys.all, 'detail'],
detail: (id) => [...eventKeys.details(), id],
};
// 在 list 頁面
useQuery({
queryKey: eventKeys.list({ status: 'active', page: 1 }),
queryFn: fetchEventList,
});
// mutation 後
queryClient.invalidateQueries({ queryKey: eventKeys.lists(), exact: false });
// 這會失效 list 底下的全部,但保留 detail
`)
小標('模式三:分離穩定部分與變動部分')
console.log(`
// ❌ 容易出錯
queryKey: ['stats', {
eventId: 1,
status: 'completed',
page: 1,
sort: 'name',
search: '',
... // 很多欄位
}]
// ✅ 清楚的意圖
queryKey: ['stats', 'byEvent', eventId, { status, page, sort, search }]
// 或甚至
queryKey: ['stats', 'byEvent', eventId].concat(JSON.stringify(filters))
`)
}
// ============================================================
// Part E:競態條件模擬
// ============================================================
function partE() {
分隔線('Part E:搜尋詞連打時的競態條件(模擬)')
小標('情境:使用者快速連打搜尋詞')
console.log(`
時刻 操作 Request 時刻 Response 時刻
────────────────────────────────────────────────────────────
0ms 使用者輸入「高級」
10ms 前端 fetch search=高級 (A)
50ms 使用者再打「普通」
60ms 前端 fetch search=普通 (B)
150ms ← Response B 回來
200ms ← Response A 才回來
沒有正確的 queryKey 時:
- Response A(晚到)可能會蓋掉 Response B(先到)
- 使用者搜尋「普通票」卻看到「高級票」結果
有正確的 queryKey 時:
- Response A 的 queryKey: ['tickets', '高級']
- Response B 的 queryKey: ['tickets', '普通']
- 目前 component 的 queryKey: ['tickets', '普通']
- Response A 不匹配,被存起來但不渲染
- Response B 匹配,立刻渲染
`)
小標('模擬的快取行為')
const scenarios = [
{
name: '❌ 沒有分化 queryKey',
keyGenerator: () => ['tickets'],
requests: [
{ at: 10, search: '高級', responseAt: 200 },
{ at: 60, search: '普通', responseAt: 150 },
],
},
{
name: '✅ 用 queryKey 分化',
keyGenerator: (search) => ['tickets', search],
requests: [
{ at: 10, search: '高級', responseAt: 200 },
{ at: 60, search: '普通', responseAt: 150 },
],
},
]
for (const scenario of scenarios) {
console.log(`\n${scenario.name}`)
const cache = {}
const currentKey = () => JSON.stringify(scenario.keyGenerator(scenario.requests[scenario.requests.length - 1].search))
scenario.requests.sort((a, b) => a.responseAt - b.responseAt)
console.log(' 回應到達順序:')
for (const req of scenario.requests) {
const key = JSON.stringify(scenario.keyGenerator(req.search))
console.log(` ${req.responseAt}ms: Response (search="${req.search}") 的 key=${key}`)
console.log(` 當前查詢的 key=${currentKey()}`)
console.log(` ${key === currentKey() ? '✅ 匹配,渲染' : '❌ 不匹配,忽略(或放在另一個快取)'}`)
}
}
}
// ============================================================
// Main
// ============================================================
function main() {
console.log('day25-query-key-and-cache.js')
console.log(`執行時間 ${new Date().toISOString()}`)
partA()
partB()
partC()
partD()
partE()
分隔線('五句話總結')
console.log(`
1. queryKey 是「這筆資料怎麼問出來的」的完整記錄,改變 = 新問題 = 新快取
2. Filter 改了但沒有改 queryKey 時,快取系統不知道有新問題,回傳舊答案
3. Mutation 後要失效快取時,要指定正確的 queryKey pattern,不要粗略失效所有相關
4. 競態條件透過 queryKey 的區隔自動解決:晚到的 response 的 key 不匹配,不會蓋掉新的
5. 最安全的做法:把所有會影響結果的參數都放進 queryKey,讓改變自動觸發新 fetch
`)
}
main()
「TanStack Query 和直接用 fetch 差在哪?」 這題的陷阱是「我知道有快取、有 deduping」,但答不出為什麼這些東西需要靠 queryKey:
fetch('/api/stats?status=all') 和 fetch('/api/stats?status=completed') 回傳的內容不同,但你的快取系統怎麼知道這是兩筆不同的問題?放在同一個記憶體位址嗎?那改 filter 時要手動清快取?最容易拉開差距的地方:你是「背」invalidateQueries 的用法,還是「理解」invalidateQueries 為什麼要跟 queryKey 對應。
把上面那段 day25-query-key-and-cache.js 存下來,跑:
node day25-query-key-and-cache.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const statsKeys = {
all: ['stats'] as const,
byFilter: (filter: StatsFilter) => [...statsKeys.all, 'byFilter', filter] as const,
};
// ✅ 使用
export function useStats(filter: StatsFilter) {
return useQuery({
queryKey: statsKeys.byFilter(filter),
queryFn: () => fetchStats(filter),
});
}
// ✅ Mutation + Invalidation
export function useUpdateChannel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateChannel,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: statsKeys.byFilter({ channelId: data.channelId }),
});
},
});
}
特點:hook 最直覺,queryKey 改變自動 refetch,無需手動追蹤 dependency
<script setup lang="ts">
import { computed } from 'vue';
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query';
const props = defineProps<{ filter: StatsFilter }>();
const statsKeys = {
all: ['stats'] as const,
byFilter: (f: StatsFilter) => [...statsKeys.all, 'byFilter', f] as const,
};
// ✅ 需要用 computed 讓 queryKey 反應式
const { data: stats } = useQuery({
queryKey: computed(() => statsKeys.byFilter(props.filter)),
queryFn: () => fetchStats(props.filter),
});
// ✅ Mutation + Invalidation
const queryClient = useQueryClient();
const { mutate: updateChannel } = useMutation({
mutationFn: (data: UpdateData) => updateChannelAPI(data),
onSuccess: (response) => {
queryClient.invalidateQueries({
queryKey: statsKeys.byFilter({ channelId: response.channelId }),
});
},
});
</script>
<template>
<div>Sold: {{ stats?.sold }}</div>
</template>
特點:queryKey 要用 computed() 包才會反應式,invalidation 語法完全相同
import { Component, Input } from '@angular/core';
import { injectQuery, injectMutation, injectQueryClient } from '@tanstack/angular-query-experimental';
@Component({
selector: 'app-stats',
template: `
<div>Sold: {{ (stats$ | async)?.sold }}</div>
`,
})
export class StatsComponent {
@Input() filter: StatsFilter;
private queryClient = injectQueryClient();
// ✅ injectQuery 返回 Observable
stats$ = injectQuery(() => ({
queryKey: ['stats', 'byFilter', this.filter],
queryFn: () => this.fetchStats(this.filter),
})).data$;
// ✅ Mutation
updateChannel$ = injectMutation(() => ({
mutationFn: (data: UpdateData) => this.updateChannelAPI(data),
onSuccess: (response) => {
this.queryClient.invalidateQueries({
queryKey: ['stats', 'byFilter', { channelId: response.channelId }],
});
},
}));
private fetchStats(filter: StatsFilter): Promise<StatsData> {
// ...
}
private updateChannelAPI(data: UpdateData): Promise<Response> {
// ...
}
}
特點:用 Observable + async pipe,要記得取 .data$,invalidation 語法同 React
<script lang="ts">
import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';
export let filter: StatsFilter;
const statsKeys = {
all: ['stats'] as const,
byFilter: (f: StatsFilter) => [...statsKeys.all, 'byFilter', f] as const,
};
const queryClient = useQueryClient();
// ✅ 用 $derived 讓 queryKey 自動追蹤
const stats = createQuery({
queryKey: $derived([statsKeys.byFilter(filter)]),
queryFn: () => fetchStats(filter),
});
// ✅ Mutation
const updateChannel = createMutation({
mutationFn: (data: UpdateData) => updateChannelAPI(data),
onSuccess: (response) => {
queryClient.invalidateQueries({
queryKey: statsKeys.byFilter({ channelId: response.channelId }),
});
},
});
</script>
<div>Sold: {$stats.data?.sold}</div>
特點:語法最簡潔,$derived 自動追蹤,$ prefix 直接存取 store
| 方面 | React | Vue | Angular | Svelte |
|---|---|---|---|---|
| queryKey 定義 | 完全相同 | 完全相同 | 完全相同 | 完全相同 |
| invalidation 語法 | queryClient.invalidateQueries(...) |
相同 | 相同 | 相同 |
| 差異 | - | 需要 computed() 包 |
Observable + async pipe |
$derived + $ prefix |
最重要的結論:queryKey 的設計和失效策略在四個框架中完全一致。唯一的差異只在「怎麼讓 component 對 queryKey 變化做出反應」。
packages/query-core/src/utils.ts 裡今天讀的是「快取鍵值配對怎麼設計」。
明天 Day 26 讀「快取回傳後,React 要不要重繪」。同一筆快取的資料回傳給 component,什麼時候值得加 useMemo 避免不必要的渲染,什麼時候這個最佳化反而把事情搞更複雜?
| 內容 | 連結 | 查證時間 |
|---|---|---|
| TanStack Query(React Query)官方文件 | https://tanstack.com/query/latest/docs/react/overview | 2026-09-25 |
| queryKey 設計最佳實踐 | https://tanstack.com/query/latest/docs/react/important-defaults | 2026-09-25 |
| useQuery 與 invalidateQueries | https://tanstack.com/query/latest/docs/react/reference/useQuery | 2026-09-25 |
| 深度物件比較的實作細節 | https://github.com/TanStack/query | 2026-09-25(未讀原始碼,推測) |