<CodeLearn/>
状態管理 レッスン4

Zustand / Jotai

軽量でモダンな状態管理ライブラリを使いこなそう

Zustandでストアを作成

Zustand(ドイツ語で「状態」の意味)は、 極めてシンプルなAPIを持つ状態管理ライブラリです。 Providerが不要で、フックベースで直感的に使えます。

// store/useCounterStore.ts
import { create } from "zustand";

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  incrementBy: (amount: number) => void;
}

// create でストアを作成(Providerは不要!)
const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
  incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}));

export default useCounterStore;

// コンポーネントで使う(シンプル!)
function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();

  return (
    <div>
      <h2>カウント: {count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

ReduxのようなProvider、dispatch、actionの概念が不要で、 通常のフックのように使えるのがZustandの最大の特徴です。

セレクタによるパフォーマンス最適化

Zustandではセレクタを使って 必要な値だけを取得できます。これにより不要な再レンダリングを防ぎます。

// 大きなストアの例
interface AppState {
  user: { name: string; email: string } | null;
  theme: "light" | "dark";
  notifications: string[];
  cartItems: CartItem[];
  setUser: (user: AppState["user"]) => void;
  toggleTheme: () => void;
  addNotification: (msg: string) => void;
}

const useAppStore = create<AppState>((set) => ({
  user: null,
  theme: "light",
  notifications: [],
  cartItems: [],
  setUser: (user) => set({ user }),
  toggleTheme: () => set((s) => ({
    theme: s.theme === "light" ? "dark" : "light",
  })),
  addNotification: (msg) => set((s) => ({
    notifications: [...s.notifications, msg],
  })),
}));

// 悪い例:ストア全体を取得(何が変わっても再レンダリング)
function BadComponent() {
  const store = useAppStore(); // 全部取得
  return <p>{store.user?.name}</p>;
}

// 良い例:セレクタで必要な値だけ取得
function GoodComponent() {
  const userName = useAppStore((state) => state.user?.name);
  // user.name が変わった時だけ再レンダリング
  return <p>{userName}</p>;
}

// 複数の値を取得する場合は shallow 比較を使う
import { shallow } from "zustand/shallow";

function UserInfo() {
  const { name, email } = useAppStore(
    (state) => ({ name: state.user?.name, email: state.user?.email }),
    shallow // オブジェクトの中身で比較(参照比較ではなく)
  );
  return <p>{name} ({email})</p>;
}

Zustandのミドルウェア

Zustandはミドルウェアで機能を拡張できます。永続化DevTools連携Immerサポートなどが用意されています。

import { create } from "zustand";
import { persist } from "zustand/middleware";
import { devtools } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

// localStorage に自動保存
const useSettingsStore = create<SettingsState>()(
  persist(
    (set) => ({
      theme: "dark",
      language: "ja",
      setTheme: (theme) => set({ theme }),
      setLanguage: (lang) => set({ language: lang }),
    }),
    {
      name: "settings-storage", // localStorageのキー
    }
  )
);

// DevTools + Immer + Persist を組み合わせ
const useCartStore = create<CartState>()(
  devtools(
    persist(
      immer((set) => ({
        items: [],
        addItem: (product: Product) =>
          set((state) => {
            // Immerのおかげで直接変更できる
            const existing = state.items.find((i) => i.id === product.id);
            if (existing) {
              existing.quantity += 1;
            } else {
              state.items.push({ ...product, quantity: 1 });
            }
          }),
        removeItem: (id: string) =>
          set((state) => {
            state.items = state.items.filter((i) => i.id !== id);
          }),
      })),
      { name: "cart-storage" }
    ),
    { name: "CartStore" } // DevToolsでの表示名
  )
);

Jotaiのアトミックな状態管理

Jotai(日本語の「状態」が由来)は、atom(アトム)という最小単位で状態を管理するライブラリです。 Recoilに影響を受けたボトムアップ型のアプローチが特徴です。

import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";

// プリミティブなatom(基本の状態単位)
const countAtom = atom(0);
const nameAtom = atom("太郎");
const darkModeAtom = atom(false);

// 派生atom(他のatomから計算される値)
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// 読み書きatom(カスタムの更新ロジック)
const uppercaseNameAtom = atom(
  (get) => get(nameAtom).toUpperCase(),       // getter
  (get, set, newName: string) => {             // setter
    set(nameAtom, newName.trim());
  }
);

// コンポーネントで使う
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const doubleCount = useAtomValue(doubleCountAtom); // 読み取り専用

  return (
    <div>
      <p>カウント: {count}(2倍: {doubleCount})</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
}

function NameEditor() {
  const [name, setName] = useAtom(uppercaseNameAtom);
  const setDarkMode = useSetAtom(darkModeAtom); // 書き込み専用

  return (
    <div>
      <p>名前: {name}</p>
      <input onChange={(e) => setName(e.target.value)} />
      <button onClick={() => setDarkMode((prev) => !prev)}>
        テーマ切替
      </button>
    </div>
  );
}

// 非同期atom
const userAtom = atom(async () => {
  const res = await fetch("/api/user");
  return res.json();
});

function UserProfile() {
  const [user] = useAtom(userAtom); // Suspenseと組み合わせ可能
  return <p>{user.name}</p>;
}

JotaiはReactのuseStateに近い感覚で使えます。 atomを必要な分だけ定義する「ボトムアップ」のアプローチが特徴です。

Redux vs Zustand vs Jotai 比較

それぞれの特徴を比較して、プロジェクトに最適なライブラリを選びましょう。

// ========== 比較表 ==========
//
// 項目           | Redux Toolkit   | Zustand         | Jotai
// --------------|-----------------|-----------------|----------------
// バンドルサイズ  | 大きい(~11KB) | 小さい(~1KB)  | 小さい(~2KB)
// 学習コスト      | 高い            | 低い            | 低い
// ボイラープレート | やや多い        | 少ない          | 非常に少ない
// DevTools       | 強力            | 対応            | 対応
// ミドルウェア    | 豊富            | あり            | 限定的
// TypeScript     | 良好            | 良好            | 良好
// Provider       | 必要            | 不要            | 任意
// 設計アプローチ  | トップダウン     | トップダウン     | ボトムアップ
//
// 選び方の目安:
//
// Redux Toolkit を選ぶ場面:
// - 大規模アプリ、チーム開発
// - Redux DevToolsを活用したデバッグが必要
// - RTK Queryでサーバー状態も一元管理したい
// - 既存のReduxプロジェクトのマイグレーション
//
// Zustand を選ぶ場面:
// - シンプルさを重視
// - 小〜中規模アプリ
// - ボイラープレートを最小限にしたい
// - Providerなしで手軽に始めたい
//
// Jotai を選ぶ場面:
// - React的な設計が好み(atomはuseStateの拡張版)
// - 細粒度のリアクティビティが必要
// - Suspenseとの統合を活用したい
// - 派生状態(computed)を多用する

まとめ

  • Zustandcreateでストアを作り、フックとして直接使える
  • セレクタで必要な値だけ取得し、不要な再レンダリングを防ぐ
  • persist、devtools、immerなどのミドルウェアで機能を拡張できる
  • Jotaiatom単位で状態を管理するボトムアップ型のアプローチ
  • プロジェクトの規模・チーム・要件に応じて最適なライブラリを選択しよう