測驗:TanStack Query + Zustand 實戰整合
共 5 題,點選答案後會立即顯示結果
1. 在整合 TanStack Query 與 Zustand 的專案中,以下哪個目錄配置是正確的職責分配?
2. 根據狀態分類決策樹,「購物車商品」應該使用哪個工具來管理?
3. 當 Zustand 的篩選條件改變時,要讓 TanStack Query 自動重新查詢,關鍵是什麼?
4. 為什麼不應該把伺服器資料存進 Zustand store?
5. 在結帳成功後需要清空購物車並重新整理訂單列表,下列哪種做法最正確?
前言
經過前面四篇的學習,你已經掌握了 TanStack Query 處理伺服器狀態、Zustand 管理客戶端狀態的核心概念。現在最重要的問題是:在真實專案中,如何讓這兩個工具協同運作?
本篇將帶你建立一套清晰的架構思維,讓你在面對任何狀態時,都能快速判斷該用哪個工具,並優雅地處理兩者間的互動。
學習目標
讀完本篇後,你能夠:
- 在同一專案中正確整合 TanStack Query 與 Zustand
- 設計清晰的狀態分層:哪些放 Query,哪些放 Zustand
- 處理兩者之間的互動場景(如:根據 client state 決定 query 參數)
專案結構建議
首先,讓我們看看一個整合兩者的專案該如何組織檔案:
src/
├── api/ # API 相關
│ ├── client.ts # axios/fetch 設定
│ └── products.ts # 商品 API 函式
│
├── hooks/ # TanStack Query hooks
│ ├── useProducts.ts # 商品查詢 hook
│ └── useCart.ts # 購物車相關 query hook
│
├── stores/ # Zustand stores
│ ├── cartStore.ts # 購物車狀態
│ └── filterStore.ts # 篩選條件狀態
│
├── components/ # React 元件
└── App.tsx
Code language: PHP (php)為什麼這樣分?
api/:純粹的 API 呼叫函式,不含任何狀態邏輯hooks/:TanStack Query 的 hooks,處理伺服器狀態stores/:Zustand stores,處理客戶端狀態
這種分離讓每個檔案的職責清晰:看到 hooks/ 就知道是伺服器資料,看到 stores/ 就知道是本地狀態。
狀態分類決策樹
面對一個新的狀態需求,用這個決策樹快速判斷:
這個資料從哪來?
│
├─ 來自 API/伺服器 → 用 TanStack Query
│ 例如:商品列表、用戶資料、訂單記錄
│
└─ 來自使用者操作,只存在前端 → 用 Zustand
│
├─ 只在單一元件內使用 → 考慮用 useState
│ 例如:modal 開關、表單輸入中的值
│
└─ 需要跨元件共享 → 用 Zustand
例如:購物車、篩選條件、用戶偏好設定
實際判斷範例:
| 狀態 | 來源 | 選擇 | 原因 |
|---|---|---|---|
| 商品列表 | API | TanStack Query | 需要快取、背景更新 |
| 購物車商品 | 用戶操作 | Zustand | 純前端狀態,跨頁共享 |
| 搜尋關鍵字 | 用戶輸入 | Zustand | 影響多個元件的查詢 |
| 單一商品詳情 | API | TanStack Query | 伺服器資料 |
| Modal 開關 | 用戶操作 | useState | 單一元件內使用 |
實戰案例:電商網站
讓我們用一個電商網站的例子,示範兩者如何協作。
場景描述
- 商品列表從 API 取得(TanStack Query)
- 購物車是純前端狀態(Zustand)
- 篩選條件(價格、分類)影響商品查詢
步驟一:建立 Zustand Store
// stores/cartStore.ts
import { create } from 'zustand'
interface CartItem {
productId: string
quantity: number
}
interface CartStore {
items: CartItem[]
addItem: (productId: string) => void
removeItem: (productId: string) => void
clearCart: () => void
}
export const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (productId) => set((state) => {
const existing = state.items.find(item => item.productId === productId)
if (existing) {
return {
items: state.items.map(item =>
item.productId === productId
? { ...item, quantity: item.quantity + 1 }
: item
)
}
}
return { items: [...state.items, { productId, quantity: 1 }] }
}),
removeItem: (productId) => set((state) => ({
items: state.items.filter(item => item.productId !== productId)
})),
clearCart: () => set({ items: [] })
}))
Code language: JavaScript (javascript)// stores/filterStore.ts
import { create } from 'zustand'
interface FilterStore {
category: string | null
minPrice: number | null
maxPrice: number | null
setCategory: (category: string | null) => void
setPriceRange: (min: number | null, max: number | null) => void
}
export const useFilterStore = create<FilterStore>((set) => ({
category: null,
minPrice: null,
maxPrice: null,
setCategory: (category) => set({ category }),
setPriceRange: (min, max) => set({ minPrice: min, maxPrice: max })
}))
Code language: JavaScript (javascript)步驟二:建立 TanStack Query Hook
// hooks/useProducts.ts
import { useQuery } from '@tanstack/react-query'
import { useFilterStore } from '../stores/filterStore'
interface Product {
id: string
name: string
price: number
category: string
}
// API 函式
async function fetchProducts(filters: {
category?: string | null
minPrice?: number | null
maxPrice?: number | null
}): Promise<Product[]> {
const params = new URLSearchParams()
if (filters.category) params.set('category', filters.category)
if (filters.minPrice) params.set('minPrice', String(filters.minPrice))
if (filters.maxPrice) params.set('maxPrice', String(filters.maxPrice))
const response = await fetch(`/api/products?${params}`)
return response.json()
}
// 整合 Zustand 狀態的 Query Hook
export function useProducts() {
// 從 Zustand 取得篩選條件
const { category, minPrice, maxPrice } = useFilterStore()
return useQuery({
// 關鍵:將篩選條件納入 queryKey
queryKey: ['products', { category, minPrice, maxPrice }],
queryFn: () => fetchProducts({ category, minPrice, maxPrice })
})
}
Code language: HTML, XML (xml)這裡的關鍵設計:
useFilterStore()取得 Zustand 的篩選狀態- 將篩選條件放入
queryKey - 當 Zustand 狀態改變時,queryKey 改變,自動觸發重新查詢
步驟三:在元件中使用
// components/ProductList.tsx
import { useProducts } from '../hooks/useProducts'
import { useCartStore } from '../stores/cartStore'
import { useFilterStore } from '../stores/filterStore'
function ProductList() {
// TanStack Query:取得商品資料
const { data: products, isLoading, error } = useProducts()
// Zustand:購物車操作
const addItem = useCartStore(state => state.addItem)
if (isLoading) return <div>載入中...</div>
if (error) return <div>載入失敗</div>
return (
<div className="product-grid">
{products?.map(product => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addItem(product.id)}>
加入購物車
</button>
</div>
))}
</div>
)
}
Code language: JavaScript (javascript)// components/FilterPanel.tsx
import { useFilterStore } from '../stores/filterStore'
function FilterPanel() {
const { category, setCategory, setPriceRange } = useFilterStore()
return (
<div className="filter-panel">
<select
value={category || ''}
onChange={(e) => setCategory(e.target.value || null)}
>
<option value="">全部分類</option>
<option value="electronics">電子產品</option>
<option value="clothing">服飾</option>
</select>
<button onClick={() => setPriceRange(0, 1000)}>
$1000 以下
</button>
<button onClick={() => setPriceRange(1000, 5000)}>
$1000 - $5000
</button>
</div>
)
}
Code language: JavaScript (javascript)// components/Cart.tsx
import { useCartStore } from '../stores/cartStore'
function Cart() {
const { items, removeItem, clearCart } = useCartStore()
return (
<div className="cart">
<h2>購物車 ({items.length} 件)</h2>
{items.map(item => (
<div key={item.productId}>
商品 {item.productId} x {item.quantity}
<button onClick={() => removeItem(item.productId)}>移除</button>
</div>
))}
<button onClick={clearCart}>清空購物車</button>
</div>
)
}
Code language: JavaScript (javascript)進階場景:雙向互動
有時候不只是 Zustand 影響 Query,還需要 Query 結果影響 Zustand。
場景:預設選中第一個分類
// hooks/useCategories.ts
import { useQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useFilterStore } from '../stores/filterStore'
export function useCategories() {
const setCategory = useFilterStore(state => state.setCategory)
const currentCategory = useFilterStore(state => state.category)
const query = useQuery({
queryKey: ['categories'],
queryFn: () => fetch('/api/categories').then(res => res.json())
})
// 當分類載入完成且尚未選擇時,預設選中第一個
useEffect(() => {
if (query.data && query.data.length > 0 && !currentCategory) {
setCategory(query.data[0].id)
}
}, [query.data, currentCategory, setCategory])
return query
}
Code language: JavaScript (javascript)注意事項:
- 使用
useEffect處理副作用 - 加入條件判斷避免無限迴圈(
!currentCategory)
場景:購物車結帳後清空並重新整理訂單
// hooks/useCheckout.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCartStore } from '../stores/cartStore'
export function useCheckout() {
const queryClient = useQueryClient()
const clearCart = useCartStore(state => state.clearCart)
const cartItems = useCartStore(state => state.items)
return useMutation({
mutationFn: () => fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ items: cartItems })
}),
onSuccess: () => {
// 清空 Zustand 購物車
clearCart()
// 讓訂單列表重新查詢
queryClient.invalidateQueries({ queryKey: ['orders'] })
}
})
}
Code language: JavaScript (javascript)常見錯誤與最佳實踐
錯誤一:把伺服器資料存進 Zustand
// 錯誤做法
const useProductStore = create((set) => ({
products: [],
fetchProducts: async () => {
const data = await fetch('/api/products').then(res => res.json())
set({ products: data }) // 不要這樣做!
}
}))
// 正確做法:用 TanStack Query
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(res => res.json())
})
Code language: JavaScript (javascript)為什麼錯? 你會失去 TanStack Query 提供的快取、背景更新、錯誤重試等所有好處。
錯誤二:在 Query 內部直接修改 Zustand
// 錯誤做法
useQuery({
queryKey: ['products'],
queryFn: async () => {
const data = await fetchProducts()
useFilterStore.getState().setCategory(data[0].category) // 不要這樣!
return data
}
})
// 正確做法:用 useEffect 處理副作用
const query = useQuery({ queryKey: ['products'], queryFn: fetchProducts })
useEffect(() => {
if (query.data) {
useFilterStore.getState().setCategory(query.data[0].category)
}
}, [query.data])
Code language: JavaScript (javascript)為什麼錯? queryFn 應該是純函式,只負責取資料。副作用應該在外部用 useEffect 處理。
錯誤三:忘記把相依的 Zustand 狀態放進 queryKey
// 錯誤做法
const category = useFilterStore(state => state.category)
useQuery({
queryKey: ['products'], // 沒有包含 category!
queryFn: () => fetchProducts({ category })
})
// 正確做法
useQuery({
queryKey: ['products', { category }], // category 改變時會重新查詢
queryFn: () => fetchProducts({ category })
})
Code language: JavaScript (javascript)為什麼錯? 當 category 改變時,queryKey 沒變,TanStack Query 不會重新查詢,畫面顯示的還是舊資料。
最佳實踐總結
| 實踐 | 說明 |
|---|---|
| 伺服器資料用 Query | 善用快取、自動更新等功能 |
| 客戶端狀態用 Zustand | 購物車、UI 狀態、用戶偏好 |
| queryKey 包含相依狀態 | 確保狀態改變時自動重查 |
| 副作用放 useEffect | 不要在 queryFn 內修改狀態 |
| 檔案分層清晰 | hooks/ 放 Query,stores/ 放 Zustand |
整合架構圖
┌─────────────────────────────────────────────────────────┐
│ React 元件 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ useProducts() │ │ useCartStore() │ │
│ │ TanStack Query │ │ Zustand │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ │ queryKey 包含 │ │
│ │◄───篩選條件──────────────┤ │
│ │ │ │
│ ┌────────▼────────┐ ┌────────▼────────┐ │
│ │ 伺服器狀態 │ │ 客戶端狀態 │ │
│ │ - 商品列表 │ │ - 購物車 │ │
│ │ - 訂單記錄 │ │ - 篩選條件 │ │
│ │ - 用戶資料 │ │ - UI 狀態 │ │
│ └────────┬────────┘ └─────────────────┘ │
│ │ │
└────────────┼────────────────────────────────────────────┘
│
▼
┌─────────────┐
│ API 伺服器 │
└─────────────┘
小結
本篇的核心觀念:
- 職責分離:TanStack Query 管伺服器資料,Zustand 管客戶端狀態
- 單向資料流:Zustand 狀態 -> queryKey -> 觸發 Query
- 副作用處理:用 useEffect 處理 Query 結果對 Zustand 的影響
- 檔案組織:hooks/、stores/、api/ 各司其職
掌握這套架構,你就能在任何 React 專案中優雅地處理複雜的狀態管理需求。
系列回顧
恭喜你完成本系列!讓我們回顧一下學到的內容:
- #01 狀態管理概論:理解 Server State vs Client State 的區別
- #02 TanStack Query 基礎:useQuery、queryKey、快取機制
- #03 TanStack Query 進階:useMutation、樂觀更新、invalidateQueries
- #04 Zustand 完整入門:create、選擇器、持久化
- #05 實戰整合:架構設計、狀態分類、協作模式
現在你已經具備了在真實專案中運用這兩個工具的能力。開始動手實作吧!
進階測驗:TanStack Query + Zustand 實戰整合
共 5 題,包含情境題與錯誤診斷題。
1. 你正在開發電商網站的商品列表頁面 情境題
需求:使用者可以透過側邊欄的篩選器(分類、價格範圍)來篩選商品。篩選條件改變時,商品列表要自動更新。篩選條件需要在多個元件間共享(篩選面板、商品列表、麵包屑導航)。
你應該如何設計這個功能的狀態管理?
2. 你需要實作一個功能:當分類列表從 API 載入完成後,自動選中第一個分類 情境題
分類列表由 TanStack Query 的 useCategories hook 取得,選中的分類存在 Zustand 的 filterStore 中。
下列哪種實作方式最正確?
3. 小華的商品查詢在篩選條件改變時沒有重新載入,請診斷問題 錯誤診斷
當使用者在 FilterPanel 中切換分類時,商品列表沒有更新。最可能的原因是什麼?
4. 小明把商品資料存進 Zustand,但發現許多問題 錯誤診斷
這種做法會導致什麼問題?
5. 你需要決定 Modal 開關狀態要用什麼管理 情境題
情境:商品詳情頁有一個「放大圖片」的 Modal,點擊商品圖片時開啟,點擊關閉按鈕或背景時關閉。這個 Modal 只在商品詳情元件內使用。
根據狀態分類決策樹,你應該用什麼來管理這個 Modal 的開關狀態?