【TanStack Query + Zustand 教學】#05 實戰整合:打造完整的狀態管理架構

測驗:TanStack Query + Zustand 實戰整合

共 5 題,點選答案後會立即顯示結果

1. 在整合 TanStack Query 與 Zustand 的專案中,以下哪個目錄配置是正確的職責分配?

  • A. hooks/ 放 Zustand stores,stores/ 放 TanStack Query hooks
  • B. api/ 放所有狀態邏輯,hooks/ 放純 API 呼叫函式
  • C. hooks/ 放 TanStack Query hooks,stores/ 放 Zustand stores
  • D. stores/ 同時放 TanStack Query 和 Zustand 的程式碼

2. 根據狀態分類決策樹,「購物車商品」應該使用哪個工具來管理?

  • A. TanStack Query,因為購物車需要快取功能
  • B. Zustand,因為這是純前端狀態且需要跨元件共享
  • C. useState,因為購物車只在單一頁面使用
  • D. TanStack Query,因為購物車資料最終要送到伺服器

3. 當 Zustand 的篩選條件改變時,要讓 TanStack Query 自動重新查詢,關鍵是什麼?

  • A. 在 Zustand store 中呼叫 refetch()
  • B. 使用 useEffect 監聽 Zustand 狀態變化後手動觸發查詢
  • C. 在 queryFn 中直接讀取 Zustand 狀態
  • D. 將篩選條件納入 queryKey

4. 為什麼不應該把伺服器資料存進 Zustand store?

  • A. Zustand 不支援非同步操作
  • B. Zustand 的效能比 TanStack Query 差
  • C. 會失去 TanStack Query 提供的快取、背景更新、錯誤重試等功能
  • D. Zustand store 無法存放陣列資料

5. 在結帳成功後需要清空購物車並重新整理訂單列表,下列哪種做法最正確?

return useMutation({ mutationFn: () => fetch(‘/api/checkout’, { … }), onSuccess: () => { // 這裡要做什麼? } })
  • A. 在 onSuccess 中只呼叫 clearCart(),訂單列表會自動更新
  • B. 在 onSuccess 中呼叫 clearCart()queryClient.invalidateQueries({ queryKey: ['orders'] })
  • C. 在 mutationFn 中直接呼叫 clearCart() 和更新訂單
  • D. 不需要手動處理,TanStack Query 會自動偵測資料變化

前言

經過前面四篇的學習,你已經掌握了 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)

這裡的關鍵設計:

  1. useFilterStore() 取得 Zustand 的篩選狀態
  2. 將篩選條件放入 queryKey
  3. 當 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 伺服器 │
      └─────────────┘

小結

本篇的核心觀念:

  1. 職責分離:TanStack Query 管伺服器資料,Zustand 管客戶端狀態
  2. 單向資料流:Zustand 狀態 -> queryKey -> 觸發 Query
  3. 副作用處理:用 useEffect 處理 Query 結果對 Zustand 的影響
  4. 檔案組織: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. 你正在開發電商網站的商品列表頁面 情境題

需求:使用者可以透過側邊欄的篩選器(分類、價格範圍)來篩選商品。篩選條件改變時,商品列表要自動更新。篩選條件需要在多個元件間共享(篩選面板、商品列表、麵包屑導航)。

你應該如何設計這個功能的狀態管理?

  • A. 篩選條件用 TanStack Query 管理,商品列表用 Zustand 管理
  • B. 篩選條件用 Zustand 管理,商品列表用 TanStack Query 管理,並將篩選條件納入 queryKey
  • C. 兩者都用 Zustand 管理,在 store 中呼叫 API 取得商品
  • D. 兩者都用 TanStack Query 管理,用 mutation 更新篩選條件

2. 你需要實作一個功能:當分類列表從 API 載入完成後,自動選中第一個分類 情境題

分類列表由 TanStack Query 的 useCategories hook 取得,選中的分類存在 Zustand 的 filterStore 中。

下列哪種實作方式最正確?

  • A. 在 queryFn 內直接呼叫 useFilterStore.getState().setCategory(data[0].id)
  • B. 使用 onSuccess 回呼來設定預設分類
  • C. 在元件中使用 useEffect,當 query.data 存在且尚未選擇分類時設定預設值
  • D. 在 Zustand store 的初始化時呼叫 API 取得分類並設定預設值

3. 小華的商品查詢在篩選條件改變時沒有重新載入,請診斷問題 錯誤診斷

// hooks/useProducts.ts export function useProducts() { const category = useFilterStore(state => state.category) return useQuery({ queryKey: [‘products’], queryFn: () => fetchProducts({ category }) }) }

當使用者在 FilterPanel 中切換分類時,商品列表沒有更新。最可能的原因是什麼?

  • A. useFilterStore 的選擇器寫法錯誤
  • B. queryKey 沒有包含 category,所以狀態改變時 Query 不會重新執行
  • C. fetchProducts 沒有正確接收參數
  • D. 需要在 queryFn 中加入 enabled 選項

4. 小明把商品資料存進 Zustand,但發現許多問題 錯誤診斷

// stores/productStore.ts const useProductStore = create((set) => ({ products: [], isLoading: false, error: null, fetchProducts: async () => { set({ isLoading: true }) try { const data = await fetch(‘/api/products’).then(res => res.json()) set({ products: data, isLoading: false }) } catch (err) { set({ error: err, isLoading: false }) } } }))

這種做法會導致什麼問題?

  • A. Zustand 不支援非同步 action,這段程式碼會報錯
  • B. 每次呼叫 fetchProducts 都會建立新的 store 實例
  • C. isLoading 和 error 狀態無法正確更新
  • D. 失去 TanStack Query 的快取、背景更新、stale-while-revalidate、錯誤重試等功能

5. 你需要決定 Modal 開關狀態要用什麼管理 情境題

情境:商品詳情頁有一個「放大圖片」的 Modal,點擊商品圖片時開啟,點擊關閉按鈕或背景時關閉。這個 Modal 只在商品詳情元件內使用。

根據狀態分類決策樹,你應該用什麼來管理這個 Modal 的開關狀態?

  • A. TanStack Query,因為需要追蹤 Modal 的狀態變化
  • B. Zustand,因為所有 UI 狀態都應該用 Zustand 管理
  • C. useState,因為這是只在單一元件內使用的 UI 狀態
  • D. Context API,因為 Modal 可能需要在子元件中控制

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *