TanStack Query 跟 Zustand 到底差在哪?實務上我怎麼分工

2026/06/26

用實務角度整理 TanStack Query 與 Zustand 的差異,說明 Server State、Client State、API 快取、UI 狀態與 URL search params 該如何分工,避免 React 專案狀態管理越寫越亂。
Abstract state management banner

在 React 專案裡,狀態管理一直是很容易變亂的地方。

一開始功能還少的時候,可能 useState 就夠了。

後來資料越來越多,頁面之間要同步、API 要快取、表格要篩選、登入狀態要共用,最後很容易變成:

「反正不知道放哪,就先丟進 store。」

這也是我以前看過不少專案會遇到的問題。

特別是 Zustand 很輕量、很好用,寫起來又直覺,所以很容易什麼都往裡面塞。

但後來我慢慢發現,不是所有狀態都適合放進 Zustand。

尤其是 API 回來的資料。

先講結論

我現在會這樣分:

類型適合工具
從 API 取得的資料TanStack Query
API loading / error / refetch 狀態TanStack Query
filter / sort / pagination 條件Zustand 或 URL search params
modal / drawer / tab / selected itemZustand
跨頁面共用的 UI 狀態Zustand
登入使用者資料視情況,通常 TanStack Query 或 Server Session
表單輸入React Hook Form / useState
很局部的元件狀態useState

簡單講:

Server State 交給 TanStack Query,Client State 才交給 Zustand。

這句話很常聽到,但真正寫專案時,難的是判斷「這個狀態到底是哪一種」。

什麼是 Server State?

Server State 指的是「真正來源在後端」的資料。

例如:

狀態來源
文章列表API / CMS
使用者資料API / Session
商品清單API
股票資料API
收藏清單API
通知列表API

這些資料有幾個特性:

  • 不是前端自己擁有的
  • 可能會過期
  • 可能被其他人或其他裝置改變
  • 需要 loading / error / refetch
  • 可能需要快取

這種資料如果全部丟進 Zustand,前期看起來沒問題,但久了會開始出現一些麻煩。

把 API 資料放進 Zustand 會發生什麼?

假設我們把文章列表放進 Zustand:

ts
const usePostStore = create((set) => ({
  posts: [],
  isLoading: false,
  error: null,

  fetchPosts: async () => {
    set({ isLoading: true });

    try {
      const res = await fetch('/api/posts');
      const data = await res.json();

      set({
        posts: data,
        isLoading: false,
      });
    } catch (error) {
      set({
        error,
        isLoading: false,
      });
    }
  },
}));

這樣不是不能用。

但問題是,寫久之後你會開始自己處理一堆 TanStack Query 已經幫你處理好的事情。

例如:

問題你要自己處理
loading 狀態要
error 狀態要
refetch要
cache要
stale data要
request dedupe要
mutation 後更新資料要
分頁資料快取要
離開頁面後再回來是否重打 API要
多個元件共用同一份 API 資料要

結果 Zustand 會慢慢從「狀態管理」變成「自己手寫一套 API cache 系統」。

這時候維護成本就會上來。

TanStack Query 適合處理 API 狀態

同樣的文章列表,如果用 TanStack Query,大概會變這樣:

ts
function usePosts() {
  return useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });
}

使用時:

ts
const { data, isLoading, error, refetch } = usePosts();

它會直接幫你處理:

功能說明
isLoading第一次載入狀態
errorAPI 錯誤
refetch重新取得資料
queryKey快取識別
staleTime資料多久後算過期
invalidateQueriesmutation 後讓資料重新更新
request dedupe避免重複打同一支 API

所以 API 資料放 TanStack Query,通常會比放 Zustand 清楚很多。

那 Zustand 還需要嗎?

需要,而且很好用。

只是它比較適合放「前端自己擁有的狀態」。

例如:

ts
const usePostUiStore = create((set) => ({
  selectedPostId: null,
  isCreateModalOpen: false,

  setSelectedPostId: (id) => set({ selectedPostId: id }),
  openCreateModal: () => set({ isCreateModalOpen: true }),
  closeCreateModal: () => set({ isCreateModalOpen: false }),
}));

這種狀態不是從 API 來的,也不需要快取。

它只是畫面互動狀態。

這時候放 Zustand 就很合理。

實務上我會這樣分工

以一個文章列表頁來說,可能會有這些狀態:

狀態放哪裡原因
postsTanStack QueryAPI 資料
isLoadingTanStack QueryAPI loading
errorTanStack QueryAPI error
keywordURL search params / Zustand搜尋條件
categoryURL search params / Zustand篩選條件
pageURL search params / Zustand分頁條件
selectedPostIdZustandUI 選取狀態
isModalOpenZustandUI 狀態
form inputReact Hook Form表單狀態

如果這個條件需要分享網址,我會優先放 URL search params。

例如:

txt
/posts?keyword=react&category=frontend&page=2

這樣重新整理、分享連結、上一頁下一頁都比較直覺。

如果只是畫面內部互動,例如 drawer 開關、目前選哪一列,就放 Zustand。

常見錯誤:把 filter 跟資料綁太死

我以前看過一種狀況是:

ts
const usePostStore = create((set, get) => ({
  posts: [],
  keyword: '',
  category: 'all',

  fetchPosts: async () => {
    const { keyword, category } = get();

    const res = await fetch(
      `/api/posts?keyword=${keyword}&category=${category}`
    );

    const data = await res.json();

    set({ posts: data });
  },
}));

這樣寫一開始很方便,但後來會變得有點卡。

因為資料、條件、請求流程全部綁在一起。

當頁面變複雜時,很容易不知道是誰改了 keyword、誰觸發 fetch、資料什麼時候該更新。

我現在會比較偏向:

ts
const { keyword, category } = usePostFilterStore();

const postsQuery = useQuery({
  queryKey: ['posts', { keyword, category }],
  queryFn: () => fetchPosts({ keyword, category }),
});

這樣分工比較清楚:

責任誰處理
keyword / categoryZustand
API requestTanStack Query
cache keyTanStack Query
條件改變後重新請求queryKey

當 keyword 或 category 改變,queryKey 也會改變,TanStack Query 就知道要重新取得資料。

queryKey 很重要

TanStack Query 裡面,我覺得最重要的是 queryKey。

它不是隨便取一個名字而已,它代表這份資料的身份。

例如:

ts
queryKey: ['posts'];

代表全部文章。

但如果有篩選條件,就應該把條件也放進去:

ts
queryKey: ['posts', { keyword, category, page }];

這樣 TanStack Query 才知道:

React 分類第 1 頁

跟

Next.js 分類第 2 頁

是不同資料。

如果 queryKey 設計不好,很容易出現:

  • 畫面顯示舊資料
  • filter 改了但資料沒更新
  • 不同頁面吃到同一份 cache
  • mutation 後不知道要 invalidate 哪個 query

所以我會把 queryKey 當成資料流設計的一部分,而不是只把它當設定。

Mutation 後資料怎麼更新?

例如新增文章後,列表應該更新。

TanStack Query 通常會這樣處理:

ts
const queryClient = useQueryClient();

const createPostMutation = useMutation({
  mutationFn: createPost,
  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: ['posts'],
    });
  },
});

這樣做的意思是:

新增成功後,讓 posts 相關資料失效,下一次重新取得。

如果用 Zustand,就常常會變成手動更新陣列:

ts
set((state) => ({
  posts: [newPost, ...state.posts],
}));

不是不能做,但如果列表有分頁、排序、篩選,就會越來越麻煩。

因為你要自己判斷這筆新資料到底該不該出現在目前列表。

所以 mutation 後是否要手動更新 cache,要看情境。

情境作法
簡單資料可以直接更新 cache
有分頁、排序、篩選通常 invalidate 比較安全
即時互動需求很高可以 optimistic update
資料正確性比速度重要invalidate / refetch

我自己的分工原則

我現在會用幾個問題判斷狀態要放哪:

1. 這份資料真正來源在哪?

如果來源是後端,就優先 TanStack Query。

如果來源是前端互動,就考慮 Zustand 或 useState。

2. 這份資料會不會過期?

會過期,就比較像 Server State。

例如文章、商品、使用者資訊、收藏清單。

3. 這份狀態需不需要被很多元件共用?

如果只是單一元件內部狀態,用 useState 就好。

不需要為了「統一管理」全部放進 store。

4. 這份狀態需不需要出現在網址上?

如果需要分享、重新整理保留、支援返回上一頁,我會優先放 URL。

5. 這份狀態是不是純 UI?

例如 modal、drawer、tab、selected row。

這種就很適合 Zustand。

實務上的簡單架構

我會盡量讓資料流長這樣:

txt
URL / Zustand filters        ↓TanStack Query queryKey        ↓API request        ↓UI render

而不是:

txt
Zustand store├── filters├── API request├── data├── loading├── error└── cache-like logic

第一種分工會比較清楚。

第二種一開始很快,但專案變大後,store 很容易變成大型雜物間。

Zustand 不是不好,是不要拿來做錯的事

我很喜歡 Zustand。

它簡單、彈性高、不太囉嗦,拿來處理 UI state 很舒服。

但也因為太好用,所以很容易濫用。

尤其在沒有明確規範的專案裡,最後常常會變成:

txt
不知道放哪 → 放 Zustand跨元件要用 → 放 ZustandAPI 資料要共用 → 放 Zustandloading 要共用 → 放 Zustand

這樣久了,狀態管理會越來越難追。

真正的問題不是 Zustand,而是沒有先分清楚:

這是 Server State,還是 Client State?

總結

我現在會把 TanStack Query 跟 Zustand 當成兩種不同角色。

工具我會拿來處理
TanStack QueryAPI 資料、快取、loading、error、refetch、mutation
ZustandUI 狀態、跨元件互動狀態、局部但需要共享的 client state
URL search params搜尋、篩選、排序、分頁這種需要保留在網址上的條件
useState單一元件內部的小狀態
React Hook Form表單狀態

對我來說,好的狀態管理不是全部集中在同一個地方。

而是每一種狀態都有清楚的歸屬。

資料來源在後端,就讓 TanStack Query 處理。

互動狀態在前端,就交給 Zustand。

需要被網址記住的條件,就放 URL。

這樣做不一定是最炫的架構,但在實務專案裡,通常會更好維護,也更容易讓下一個接手的人看懂。


這篇文章有幫助嗎?