React Zustand 輕量級狀態管理套件

在 React 開發中,狀態管理 (State Management) 一直是一個熱門話題。除了 React 內建的 Context API 和老牌的 Redux 之外,近年來 Zustand 異軍突起,成為 React 社群中最受歡迎的輕量級狀態管理庫之一。

Zustand(德語中的「狀態」)由 Poimandres 團隊開發,它的設計哲學是:簡單、極簡、去中心化。它解決了 Context API 可能導致的效能問題(不必要的重新渲染),同時也避免了 Redux 繁瑣的 boilerplate code。

為什麼選擇 Zustand?

  1. 極簡的 API:不需要 Provider 包裹整個 App,寫法非常直觀。
  2. 效能優化:透過 Selector 機制,元件只在實際上用到的資料改變時才會重新渲染。
  3. 非同步處理簡單:不需要像 Redux Thunk 或 Saga 這樣的中介軟體,直接在 action 中寫 async/await 即可。
  4. 輕量:打包後的大小非常小。
  5. DevTools 支援:支援 Redux DevTools,方便除錯。

安裝

使用 npm 或 yarn 安裝:

npm install zustand

基本用法

使用 Zustand 分為兩個步驟:

  1. 建立 Store:定義狀態 (State) 和操作狀態的方法 (Actions)。
  2. 在元件中使用:透過 Hook 的方式讀取狀態或呼叫方法。

1. 建立 Store

通常我們會建立一個獨立的檔案(例如 store.jsuseStore.js)來定義 Store。Zustand 的 create 函式會回傳一個 Hook,通常命名為 use... 開頭。

import { create } from 'zustand';

// 建立一個存儲 "熊" 數量的 store
const useBearStore = create((set) => ({
  bears: 0,
  // 增加熊的數量
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  // 清空熊的數量
  removeAllBears: () => set({ bears: 0 }),
}));

export default useBearStore;

2. 在元件中使用

現在你可以在任何元件中直接 import 這個 Hook 來使用狀態。

import useBearStore from './store';

function BearCounter() {
  // 讀取狀態
  const bears = useBearStore((state) => state.bears);
  return <h1>目前有 {bears} 隻熊</h1>;
}

function Controls() {
  // 讀取 actions
  const increasePopulation = useBearStore((state) => state.increasePopulation);
  return <button onClick={increasePopulation}>增加一隻熊</button>;
}

// 你的 App 不需要任何 Provider!
function App() {
  return (
    <>
      <BearCounter />
      <Controls />
    </>
  );
}

狀態更新 (Updating State)

create 函式中,你會獲得一個 set 函式。這個 set 函式是用來更新狀態的。

合併狀態 (Merging)

Zustand 預設會進行「淺層合併」(shallow merge)。這意味著你只需要提供想要改變的屬性即可,Zustand 會自動保留其他層級的屬性。

const useStore = create((set) => ({
  firstName: 'John',
  lastName: 'Doe',
  updateFirstName: (name) => set({ firstName: name }), // lastName 會被保留
}));

依賴舊狀態 (Previous State)

如果新的狀態依賴於舊的狀態(例如計數器加一),set 可以接受一個 callback 函式,該函式的參數即為當前的 state。

const useStore = create((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
}));

讀取當前狀態 (Reading State via get)

create 方法中,除了 set 之外,你還可以接收 get 作為第二個參數。get() 函式允許你在 action 中讀取當前的 state。這在需要基於當前狀態進行邏輯判斷時(而非單純依賴前一個狀態更新)非常有用。

const useStore = create((set, get) => ({
  count: 0,
  action: () => {
    const currentCount = get().count;
    // 範例:只有當 count 小於 10 時才增加
    if (currentCount < 10) {
      set({ count: currentCount + 1 });
    }
  },
}));

非同步操作 (Async Actions)

Zustand 處理非同步操作非常簡單,你不需要額外的 middleware。只需要在 action 中使用 async/await,然後在完成後呼叫 set 即可。

const useDataStore = create((set) => ({
  data: null,
  loading: false,
  error: null,

  fetchData: async (url) => {
    set({ loading: true, error: null }); // 開始載入
    try {
      const response = await fetch(url);
      const data = await response.json();
      set({ data: data, loading: false }); // 載入成功
    } catch (error) {
      set({ error: error.message, loading: false }); // 發生錯誤
    }
  },
}));

Selectors 與效能優化

這是 Zustand 最強大的功能之一。當你使用 useStore() 時,你可以傳入一個 selector 函式來只「選取」你需要的狀態部分。

元件只會在 selector 回傳的值發生改變時才會重新渲染。

好的做法 ✅

// ✅ 只有當 bears 改變時,這個元件才會重新渲染
const bears = useBearStore((state) => state.bears);

不好的做法 ❌

如果你不傳入 selector,預設會回傳整個 state 物件。雖然方便,但這意味著 store 中任何一個屬性改變,該元件都會重新渲染。

// ❌ store 中任何資料改變(例如 fish 改變),這個元件都會重新渲染
const { bears } = useBearStore();

取用多個狀態 (Shallow Check)

如果你想一次取用多個狀態,但又不想因為無關的狀態改變而渲染,可以使用 useShallow (需要 zustand v5) 或者自定義 equality function。

import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

const useStore = create((set) => ({
  bear: 0,
  fish: 0,
  bird: 0,
}));

function Component() {
  // 使用 useShallow,只有當 bear 或 fish 改變時才會渲染
  // 如果 bird 改變,這個元件不會渲染
  const { bear, fish } = useStore(useShallow((state) => ({ bear: state.bear, fish: state.fish })));

  return (
    <div>
      Bear: {bear}, Fish: {fish}
    </div>
  );
}

處理巢狀物件 (Nested Objects)

雖然 Zustand 預設只做淺層合併,但更新深層巢狀物件也是可以的,只是需要寫比較多的 spread syntax (...)。

const useStore = create((set) => ({
  deep: {
    nested: {
      obj: { count: 0 },
    },
  },
  updateNested: () =>
    set((state) => ({
      deep: {
        ...state.deep,
        nested: {
          ...state.deep.nested,
          obj: {
            ...state.deep.nested.obj,
            count: state.deep.nested.obj.count + 1,
          },
        },
      },
    })),
}));

immer middleware

為了簡化這個過程,推薦使用 Immer middleware,讓你可以用 mutable 的語法來更新 immutable 的狀態。

安裝:

npm install immer

使用 immer 更新巢狀狀態:

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

const useStore = create(
  immer((set) => ({
    deep: {
      nested: {
        obj: { count: 0 },
      },
    },
    updateNested: () =>
      set((state) => {
        // 直接修改 draft state
        state.deep.nested.obj.count += 1;
      }),
  })),
);

資料持久化 (Persist Middleware)

Zustand 內建了 persist middleware,可以幫你自動將 store 的狀態儲存到 localStoragesessionStorageAsyncStorage 中。當使用者重新整理頁面時,狀態會自動還原。

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

const useStore = create(
  persist(
    (set, get) => ({
      bears: 0,
      addABear: () => set({ bears: get().bears + 1 }),
    }),
    {
      name: 'food-storage', // localStorage 中的 key 名稱 (必填)
      storage: createJSONStorage(() => localStorage), // (選填) 預設就是 localStorage
    },
  ),
);

除錯工具 (DevTools Middleware)

Zustand 支援 Redux DevTools Extension,這對於除錯非常有用。只需要用 devtools middleware 包裹你的 store 配置即可。

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

const useStore = create(
  devtools((set) => ({
    bears: 0,
    increase: () => set((state) => ({ bears: state.bears + 1 })),
  })),
);

如果你同時使用 persistdevtools,建議將 devtools 放在最外層(或者根據具體需求調整,通常 devtools(persist(...)))。

在元件外部使用 (Outside Components)

有時候你可能需要在 React 元件以外的地方(例如 API 攔截器、工具函式)讀取或修改狀態。由於 Zustand store 本質上就是一個 hook 附帶著一些方法,你可以直接使用 getStatesetState

import useStore from './store';

// 1. 讀取狀態
const count = useStore.getState().count;

// 2. 修改狀態
useStore.setState({ count: 100 });

// 3. 訂閱狀態變化 (類似 useEffect)
const unsub = useStore.subscribe((state, prevState) => {
  console.log('狀態改變了:', state);
});

// 取消訂閱
unsub();

大型應用程式架構 (Slice Pattern)

當你的 App 變得很大時,將所有的狀態和方法寫在一個檔案會變得很難維護。Zustand 推薦使用 Slice Pattern 來分割你的 store。

你可以建立多個 "Slice",每個 Slice 負責一部分的功能,最後再將它們合併成一個 Store。

// createBearSlice.js
const createBearSlice = (set) => ({
  bears: 0,
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  eatFish: () => set((state) => ({ fishes: state.fishes - 1 })),
});

// createFishSlice.js
const createFishSlice = (set) => ({
  fishes: 0,
  addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
});

// store.js
import { create } from 'zustand';

const useBoundStore = create((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
}));

這樣你就可以將不同領域的邏輯拆分到不同的檔案中,保持程式碼清晰。

完整範例:待辦事項清單 (Todo List)

最後,我們來看一個完整的 Todo List 範例,包含了新增、切換狀態、刪除以及過濾功能。

// store.js
import { create } from 'zustand';

const useTodoStore = create((set) => ({
  todos: [],

  // 新增 Todo
  addTodo: (text) =>
    set((state) => ({
      todos: [...state.todos, { id: Date.now(), text, completed: false }],
    })),

  // 切換完成狀態
  toggleTodo: (id) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo,
      ),
    })),

  // 刪除 Todo
  removeTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}));

export default useTodoStore;
// App.jsx
import { useState } from 'react';
import useTodoStore from './store';

function TodoList() {
  // 使用 selector 讀取 todos 和 methods
  // 注意:這裡為了方便展示一次讀取,實務上如果擔心效能,可以分開讀取
  const { todos, toggleTodo, removeTodo } = useTodoStore((state) => ({
    todos: state.todos,
    toggleTodo: state.toggleTodo,
    removeTodo: state.removeTodo,
  }));

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id} style={{ display: 'flex', gap: '10px', marginBottom: '5px' }}>
          <span
            style={{
              textDecoration: todo.completed ? 'line-through' : 'none',
              cursor: 'pointer',
            }}
            onClick={() => toggleTodo(todo.id)}
          >
            {todo.text}
          </span>
          <button onClick={() => removeTodo(todo.id)}>刪除</button>
        </li>
      ))}
    </ul>
  );
}

function AddTodo() {
  const [text, setText] = useState('');
  const addTodo = useTodoStore((state) => state.addTodo);

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    addTodo(text);
    setText('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="輸入代辦事項..." />
      <button type="submit">新增</button>
    </form>
  );
}

export default function App() {
  return (
    <div>
      <h1>Zustand Todo List</h1>
      <AddTodo />
      <TodoList />
    </div>
  );
}