2026/06/26
在 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 item | Zustand |
| 跨頁面共用的 UI 狀態 | Zustand |
| 登入使用者資料 | 視情況,通常 TanStack Query 或 Server Session |
| 表單輸入 | React Hook Form / useState |
| 很局部的元件狀態 | useState |
簡單講:
Server State 交給 TanStack Query,Client State 才交給 Zustand。
這句話很常聽到,但真正寫專案時,難的是判斷「這個狀態到底是哪一種」。
---
Server State 指的是「真正來源在後端」的資料。
例如:
| 狀態 | 來源 |
|---|---|
| 文章列表 | API / CMS |
| 使用者資料 | API / Session |
| 商品清單 | API |
| 股票資料 | API |
| 收藏清單 | API |
| 通知列表 | API |
這些資料有幾個特性:
這種資料如果全部丟進 Zustand,前期看起來沒問題,但久了會開始出現一些麻煩。
---
假設我們把文章列表放進 Zustand:
1const usePostStore = create((set) => ({
2 posts: [],
3 isLoading: false,
4 error: null,
5
6 fetchPosts: async () => {
7 set({ isLoading: true });
8
9 try {
10 const res = await fetch('/api/posts');
11 const data = await res.json();
12
13 set({
14 posts: data,
15 isLoading: false,
16 });
17 } catch (error) {
18 set({
19 error,
20 isLoading: false,
21 });
22 }
23 },
24}));這樣不是不能用。
但問題是,寫久之後你會開始自己處理一堆 TanStack Query 已經幫你處理好的事情。
例如:
| 問題 | 你要自己處理 |
|---|---|
| loading 狀態 | 要 |
| error 狀態 | 要 |
| refetch | 要 |
| cache | 要 |
| stale data | 要 |
| request dedupe | 要 |
| mutation 後更新資料 | 要 |
| 分頁資料快取 | 要 |
| 離開頁面後再回來是否重打 API | 要 |
| 多個元件共用同一份 API 資料 | 要 |
結果 Zustand 會慢慢從「狀態管理」變成「自己手寫一套 API cache 系統」。
這時候維護成本就會上來。
---
同樣的文章列表,如果用 TanStack Query,大概會變這樣:
1function usePosts() {
2 return useQuery({
3 queryKey: ['posts'],
4 queryFn: fetchPosts,
5 });
6}使用時:
1const { data, isLoading, error, refetch } = usePosts();它會直接幫你處理:
| 功能 | 說明 |
|---|---|
| `isLoading` | 第一次載入狀態 |
| `error` | API 錯誤 |
| `refetch` | 重新取得資料 |
| `queryKey` | 快取識別 |
| `staleTime` | 資料多久後算過期 |
| `invalidateQueries` | mutation 後讓資料重新更新 |
| request dedupe | 避免重複打同一支 API |
所以 API 資料放 TanStack Query,通常會比放 Zustand 清楚很多。
---
需要,而且很好用。
只是它比較適合放「前端自己擁有的狀態」。
例如:
1const usePostUiStore = create((set) => ({
2 selectedPostId: null,
3 isCreateModalOpen: false,
4
5 setSelectedPostId: (id) => set({ selectedPostId: id }),
6 openCreateModal: () => set({ isCreateModalOpen: true }),
7 closeCreateModal: () => set({ isCreateModalOpen: false }),
8}));這種狀態不是從 API 來的,也不需要快取。
它只是畫面互動狀態。
這時候放 Zustand 就很合理。
---
以一個文章列表頁來說,可能會有這些狀態:
| 狀態 | 放哪裡 | 原因 |
|---|---|---|
| `posts` | TanStack Query | API 資料 |
| `isLoading` | TanStack Query | API loading |
| `error` | TanStack Query | API error |
| `keyword` | URL search params / Zustand | 搜尋條件 |
| `category` | URL search params / Zustand | 篩選條件 |
| `page` | URL search params / Zustand | 分頁條件 |
| `selectedPostId` | Zustand | UI 選取狀態 |
| `isModalOpen` | Zustand | UI 狀態 |
| form input | React Hook Form | 表單狀態 |
如果這個條件需要分享網址,我會優先放 URL search params。
例如:
1/posts?keyword=react&category=frontend&page=2這樣重新整理、分享連結、上一頁下一頁都比較直覺。
如果只是畫面內部互動,例如 drawer 開關、目前選哪一列,就放 Zustand。
---
我以前看過一種狀況是:
1const usePostStore = create((set, get) => ({
2 posts: [],
3 keyword: '',
4 category: 'all',
5
6 fetchPosts: async () => {
7 const { keyword, category } = get();
8
9 const res = await fetch(
10 `/api/posts?keyword=${keyword}&category=${category}`
11 );
12
13 const data = await res.json();
14
15 set({ posts: data });
16 },
17}));這樣寫一開始很方便,但後來會變得有點卡。
因為資料、條件、請求流程全部綁在一起。
當頁面變複雜時,很容易不知道是誰改了 keyword、誰觸發 fetch、資料什麼時候該更新。
我現在會比較偏向:
1const { keyword, category } = usePostFilterStore();
2
3const postsQuery = useQuery({
4 queryKey: ['posts', { keyword, category }],
5 queryFn: () => fetchPosts({ keyword, category }),
6});這樣分工比較清楚:
| 責任 | 誰處理 |
|---|---|
| `keyword` / `category` | Zustand |
| API request | TanStack Query |
| cache key | TanStack Query |
| 條件改變後重新請求 | `queryKey` |
當 keyword 或 category 改變,queryKey 也會改變,TanStack Query 就知道要重新取得資料。
---
TanStack Query 裡面,我覺得最重要的是 `queryKey`。
它不是隨便取一個名字而已,它代表這份資料的身份。
例如:
1queryKey: ['posts'];代表全部文章。
但如果有篩選條件,就應該把條件也放進去:
1queryKey: ['posts', { keyword, category, page }];這樣 TanStack Query 才知道:
React 分類第 1 頁
跟
Next.js 分類第 2 頁
是不同資料。
如果 queryKey 設計不好,很容易出現:
所以我會把 queryKey 當成資料流設計的一部分,而不是只把它當設定。
---
例如新增文章後,列表應該更新。
TanStack Query 通常會這樣處理:
1const queryClient = useQueryClient();
2
3const createPostMutation = useMutation({
4 mutationFn: createPost,
5 onSuccess: () => {
6 queryClient.invalidateQueries({
7 queryKey: ['posts'],
8 });
9 },
10});這樣做的意思是:
新增成功後,讓 posts 相關資料失效,下一次重新取得。
如果用 Zustand,就常常會變成手動更新陣列:
1set((state) => ({
2 posts: [newPost, ...state.posts],
3}));不是不能做,但如果列表有分頁、排序、篩選,就會越來越麻煩。
因為你要自己判斷這筆新資料到底該不該出現在目前列表。
所以 mutation 後是否要手動更新 cache,要看情境。
| 情境 | 作法 |
|---|---|
| 簡單資料 | 可以直接更新 cache |
| 有分頁、排序、篩選 | 通常 invalidate 比較安全 |
| 即時互動需求很高 | 可以 optimistic update |
| 資料正確性比速度重要 | invalidate / refetch |
---
我現在會用幾個問題判斷狀態要放哪:
如果來源是後端,就優先 TanStack Query。
如果來源是前端互動,就考慮 Zustand 或 `useState`。
會過期,就比較像 Server State。
例如文章、商品、使用者資訊、收藏清單。
如果只是單一元件內部狀態,用 `useState` 就好。
不需要為了「統一管理」全部放進 store。
如果需要分享、重新整理保留、支援返回上一頁,我會優先放 URL。
例如 modal、drawer、tab、selected row。
這種就很適合 Zustand。
---
我會盡量讓資料流長這樣:
1URL / Zustand filters
2 ↓
3TanStack Query queryKey
4 ↓
5API request
6 ↓
7UI render而不是:
1Zustand store
2├── filters
3├── API request
4├── data
5├── loading
6├── error
7└── cache-like logic第一種分工會比較清楚。
第二種一開始很快,但專案變大後,store 很容易變成大型雜物間。
---
我很喜歡 Zustand。
它簡單、彈性高、不太囉嗦,拿來處理 UI state 很舒服。
但也因為太好用,所以很容易濫用。
尤其在沒有明確規範的專案裡,最後常常會變成:
1不知道放哪 → 放 Zustand
2跨元件要用 → 放 Zustand
3API 資料要共用 → 放 Zustand
4loading 要共用 → 放 Zustand這樣久了,狀態管理會越來越難追。
真正的問題不是 Zustand,而是沒有先分清楚:
這是 Server State,還是 Client State?
---
我現在會把 TanStack Query 跟 Zustand 當成兩種不同角色。
| 工具 | 我會拿來處理 |
|---|---|
| TanStack Query | API 資料、快取、loading、error、refetch、mutation |
| Zustand | UI 狀態、跨元件互動狀態、局部但需要共享的 client state |
| URL search params | 搜尋、篩選、排序、分頁這種需要保留在網址上的條件 |
| useState | 單一元件內部的小狀態 |
| React Hook Form | 表單狀態 |
對我來說,好的狀態管理不是全部集中在同一個地方。
而是每一種狀態都有清楚的歸屬。
資料來源在後端,就讓 TanStack Query 處理。
互動狀態在前端,就交給 Zustand。
需要被網址記住的條件,就放 URL。
這樣做不一定是最炫的架構,但在實務專案裡,通常會更好維護,也更容易讓下一個接手的人看懂。