Zustand 5 - React状態管理のミニマル選択肢

中級 | 10分 で読める | 2026.04.23

公式ドキュメント

この記事の要点

• Zustand 5は週間ダウンロード数2000万超でReact状態管理ライブラリのトップに
• Bundle sizeは1.2KBでRedux ToolkitやJotaiより軽量
• Middleware(persist/immer/devtools)で実用機能を追加可能
• React 19のServer Componentsと併用でき、TypeScript型推論が強力

Zustandとは何か

Zustandはドイツ語で「状態」を意味する軽量なReact状態管理ライブラリで、Reduxのようなボイラープレートを排除しつつ、Context APIの再レンダリング問題を解決します。pmndrs(Poimandres)コミュニティが開発し、Three.jsエコシステムでも広く使われています。

2026年4月時点でnpm週間ダウンロード数2000万超(npm trendsデータ)を記録し、Redux Toolkit・Jotai・Recoilを抜いてReact状態管理ライブラリのトップシェアとなりました。React 19のServer Components環境でも動作し、Next.js 15以降との相性が良好です。

Zustand 5の主要機能

1. 最小限のAPI設計

Zustandはストア作成が1関数で完結します。

import { create } from "zustand";

type CounterStore = {
  count: number;
  increment: () => void;
  decrement: () => void;
};

export const useCounterStore = create<CounterStore>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

Reactコンポーネントからは通常のHookと同様に使います。

// コンポーネント内
import { useCounterStore } from "./store";

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+1</button>
    </div>
  );
}

ポイント: Zustandはコンポーネント外でもストアにアクセス可能です。`useCounterStore.getState().count`で現在値を取得でき、非同期処理やテストで便利です。

2. Selectorによる最適化

Zustand 5ではSelectorの型推論が改善され、必要な状態だけを購読することで無駄な再レンダリングを防ぎます。

type UserStore = {
  user: { name: string; email: string; age: number };
  updateName: (name: string) => void;
};

export const useUserStore = create<UserStore>((set) => ({
  user: { name: "Alice", email: "alice@example.com", age: 30 },
  updateName: (name) => set((state) => ({ user: { ...state.user, name } })),
}));

// コンポーネント1: nameだけ購読
function UserName() {
  const name = useUserStore((state) => state.user.name);
  return <h1>{name}</h1>;
}

// コンポーネント2: emailだけ購読
function UserEmail() {
  const email = useUserStore((state) => state.user.email);
  return <p>{email}</p>;
}

updateNameを実行すると、UserNameコンポーネントのみ再レンダリングされ、UserEmailは再レンダリングされません(React DevTools Profilerで確認)。

3. Middleware: persist

Zustandのpersistミドルウェアは、ストア状態をlocalStorageやSessionStorageに自動保存します。

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

type SettingsStore = {
  theme: "light" | "dark";
  language: string;
  toggleTheme: () => void;
};

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      theme: "light",
      language: "en",
      toggleTheme: () => set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
    }),
    {
      name: "app-settings", // localStorageのキー名
      storage: createJSONStorage(() => localStorage),
    }
  )
);

このコードで、ページリロード後もthemelanguageが保持されます。Zustand 5ではIndexedDBやAsyncStorageにも対応しています。

実践メモ: persistミドルウェアは、ストアの一部だけを永続化する`partialize`オプションも持ちます。センシティブな情報を除外する際に有効です。

4. Middleware: immer

immerミドルウェアを使うと、Immer.jsのproduce関数が自動適用され、イミュータブルな更新が簡潔に書けます。

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

type TodoStore = {
  todos: Array<{ id: number; text: string; done: boolean }>;
  toggleTodo: (id: number) => void;
  addTodo: (text: string) => void;
};

export const useTodoStore = create<TodoStore>()(
  immer((set) => ({
    todos: [],
    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done; // 直接変更OK(Immerが内部でコピー)
      }),
    addTodo: (text) =>
      set((state) => {
        state.todos.push({ id: Date.now(), text, done: false });
      }),
  }))
);

Immer使用時はBundle sizeが約3KB増加しますが、ネストが深いオブジェクトの更新が大幅に簡潔化します。

5. Middleware: devtools

Redux DevToolsと統合し、Time-travel debuggingが可能になります。

import { devtools } from "zustand/middleware";

export const useAppStore = create<AppStore>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 }), false, "increment"),
    }),
    { name: "AppStore" }
  )
);

Redux DevTools拡張機能で状態履歴の巻き戻し・再生が可能になり、デバッグ効率が向上します。

React 19・Next.js 16との統合

Server ComponentsとZustand

React 19のServer Componentsでは、Zustandをクライアント側のみで使います。

// app/store.ts (Client Component用)
"use client";
import { create } from "zustand";

export const useCartStore = create<CartStore>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));
// app/cart/page.tsx (Server Component)
import { CartClient } from "./cart-client";

export default function CartPage() {
  return (
    <div>
      <h1>Your Cart</h1>
      <CartClient />
    </div>
  );
}
// app/cart/cart-client.tsx (Client Component)
"use client";
import { useCartStore } from "../store";

export function CartClient() {
  const items = useCartStore((state) => state.items);
  const addItem = useCartStore((state) => state.addItem);

  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>{item.name}</div>
      ))}
      <button onClick={() => addItem({ id: Date.now(), name: "New Item" })}>Add</button>
    </div>
  );
}

ポイント: ZustandストアはClient Component内でのみimportします。Server Component側では使わないため、バンドルサイズへの影響がありません。

Next.js App Routerでのpersist

Next.js 16では、localStorageがサーバーサイドで未定義のため、以下のようにハイドレーション対策が必要です。

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

export const usePreferencesStore = create<PreferencesStore>()(
  persist(
    (set) => ({
      theme: "light",
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: "preferences",
      // SSRでエラーを防ぐ
      skipHydration: true,
    }
  )
);
// コンポーネント側でハイドレーション完了を待つ
"use client";
import { useEffect, useState } from "react";
import { usePreferencesStore } from "./store";

export function ThemeSwitcher() {
  const [hydrated, setHydrated] = useState(false);
  const theme = usePreferencesStore((state) => state.theme);

  useEffect(() => {
    usePreferencesStore.persist.rehydrate();
    setHydrated(true);
  }, []);

  if (!hydrated) return null; // ハイドレーション前は非表示

  return <div>Current theme: {theme}</div>;
}

Redux Toolkit・Jotai・Valtioとの比較

ライブラリBundle size学習コストMiddlewareTypeScript推論
Zustand 51.2 KB豊富強い
Redux Toolkit12 KBredux-persist等普通
Jotai3.2 KBなし(atom組合せ)強い
Valtio4.1 KBなし(proxy利用)普通

Redux Toolkitとの使い分け
Redux Toolkitは大規模チームでのパターン統一に強みがあります。50人以上の組織ではReduxの構造化が有効ですが、中小規模ではZustandの柔軟性が生産性を高めます。

Jotai・Valtioとの比較
JotaiやValtioはAtomベース・Proxyベースのアプローチで、より宣言的です。ZustandはHookベースで既存のReactパターンに馴染みやすく、学習コストが最小です。

実践的なユースケース

ケース1: フォーム状態管理

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

type FormStore = {
  fields: { email: string; password: string; remember: boolean };
  errors: { email?: string; password?: string };
  setField: (field: keyof FormStore["fields"], value: string | boolean) => void;
  validate: () => boolean;
};

export const useFormStore = create<FormStore>()(
  immer((set, get) => ({
    fields: { email: "", password: "", remember: false },
    errors: {},
    setField: (field, value) =>
      set((state) => {
        state.fields[field] = value;
      }),
    validate: () => {
      const { email, password } = get().fields;
      const errors: FormStore["errors"] = {};

      if (!email.includes("@")) errors.email = "Invalid email";
      if (password.length < 8) errors.password = "Password too short";

      set({ errors });
      return Object.keys(errors).length === 0;
    },
  }))
);
// コンポーネント
function LoginForm() {
  const { fields, errors, setField, validate } = useFormStore();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (validate()) {
      console.log("Form valid", fields);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={fields.email} onChange={(e) => setField("email", e.target.value)} />
      {errors.email && <span>{errors.email}</span>}
      <input type="password" value={fields.password} onChange={(e) => setField("password", e.target.value)} />
      {errors.password && <span>{errors.password}</span>}
      <button type="submit">Login</button>
    </form>
  );
}

実践メモ: フォーム状態をZustandで管理すると、複数コンポーネント間でフォーム値を共有しやすくなります。React Hook Formとの併用も可能です。

ケース2: 非同期データフェッチ

type DataStore = {
  users: Array<{ id: number; name: string }>;
  loading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
};

export const useDataStore = create<DataStore>((set) => ({
  users: [],
  loading: false,
  error: null,
  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const res = await fetch("/api/users");
      const users = await res.json();
      set({ users, loading: false });
    } catch (err) {
      set({ error: err.message, loading: false });
    }
  },
}));
function UserList() {
  const { users, loading, error, fetchUsers } = useDataStore();

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

TanStack Query v5との併用も一般的で、Zustandはグローバル状態、TanStack Queryはサーバー状態という使い分けが実用的です。

ケース3: 複数ストアの組み合わせ

// 認証ストア
export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  login: async (email, password) => {
    const user = await loginAPI(email, password);
    set({ user });
  },
  logout: () => set({ user: null }),
}));

// 通知ストア
export const useNotificationStore = create<NotificationStore>((set) => ({
  notifications: [],
  addNotification: (message) =>
    set((state) => ({
      notifications: [...state.notifications, { id: Date.now(), message }],
    })),
}));

// 複合Hook
export function useAppState() {
  const user = useAuthStore((state) => state.user);
  const notifications = useNotificationStore((state) => state.notifications);
  return { user, notifications };
}

Zustandは複数ストアを独立して定義でき、Redux のような単一ストア制約がありません。

TypeScript型推論の強化

Zustand 5では型推論が改善され、setgetの型が自動推論されます。

type CounterStore = {
  count: number;
  text: string;
  increment: () => void;
  updateText: (text: string) => void;
};

// v4以前: 型注釈が必要だった
export const useStore = create<CounterStore>((set, get) => ({
  count: 0,
  text: "",
  increment: () => set((state) => ({ count: state.count + 1 })),
  updateText: (text: string) => set({ text }), // 型推論が効く
}));

// Selector使用時も型安全
const count = useStore((state) => state.count); // number型
const text = useStore((state) => state.text); // string型

ポイント: Zustand 5では、Selectorの戻り値型が自動推論されるため、型アサーションが不要です。[TypeScript 5.8](/news/typescript-5-8-2026)の改善も寄与しています。

SSRとハイドレーション

Next.js App Routerでは、サーバーとクライアントで初期状態が異なる場合にハイドレーションエラーが発生します。

// 誤った例: localStorageをサーバーで読む
export const useBadStore = create<Store>()(
  persist(
    (set) => ({ theme: "light" }),
    { name: "theme", storage: createJSONStorage(() => localStorage) } // SSRでエラー
  )
);

// 正しい例: skipHydrationを使う
export const useGoodStore = create<Store>()(
  persist(
    (set) => ({ theme: "light" }),
    {
      name: "theme",
      skipHydration: true, // サーバー側で初期化しない
      storage: createJSONStorage(() => localStorage),
    }
  )
);
// クライアント側で明示的にハイドレーション
"use client";
import { useEffect } from "react";
import { useGoodStore } from "./store";

export function HydratedTheme() {
  useEffect(() => {
    useGoodStore.persist.rehydrate(); // クライアント側で復元
  }, []);

  const theme = useGoodStore((state) => state.theme);
  return <div>Theme: {theme}</div>;
}

注意: `skipHydration: true`を指定すると、初回レンダリング時は初期値が使われます。UI のちらつきを避けるため、テーマ切り替えなどは`useEffect`後に表示する設計が推奨されます。

パフォーマンス最適化

Selectorのメモ化

import { useShallow } from "zustand/react/shallow";

// 毎回新しいオブジェクトを返すため再レンダリングされる(悪い例)
const { count, text } = useStore((state) => ({ count: state.count, text: state.text }));

// useShallowで浅い比較(良い例)
const { count, text } = useStore(useShallow((state) => ({ count: state.count, text: state.text })));

useShallowを使うと、オブジェクトのプロパティが変更されない限り再レンダリングされません

transientアクション

頻繁に更新される状態(例: マウス座標)を購読しないアクションはtransientとして定義できます。

export const useMouseStore = create<MouseStore>((set) => ({
  x: 0,
  y: 0,
  setPosition: (x, y) => set({ x, y }, true), // 第2引数trueで通知抑制
}));

// 購読せずに座標を更新
window.addEventListener("mousemove", (e) => {
  useMouseStore.getState().setPosition(e.clientX, e.clientY);
});

よくある質問

ZustandとContext APIの違いは何ですか?
Context APIは状態更新時にProvider配下の全コンポーネントが再レンダリングされますが、ZustandはSelectorで購読した部分のみ再レンダリングされます。パフォーマンスが重要な場合、Zustandが有利です。

Redux Toolkitから移行すべきですか?
プロジェクト規模とチーム構成によります。Redux ToolkitでMiddlewareやSliceパターンが機能している大規模プロジェクトでは、無理に移行する必要はありません。新規プロジェクトや中小規模では、Zustandの方が生産性が高いでしょう。

Zustandはどんなプロジェクトに向いていますか?
1〜10人規模のチームで、迅速な開発が求められるプロジェクトに最適です。スタートアップやMVP開発、社内ツールなど、柔軟性と学習コストの低さが重要な場面で力を発揮します。

まとめ

Zustand 5は、2026年のReact状態管理ライブラリとして最も実用的な選択肢の一つです。以下のポイントを再確認します。

  • Bundle size 1.2KB、週間ダウンロード数2000万超の実績
  • Middleware(persist/immer/devtools)で実用機能を柔軟に追加
  • React 19のServer Componentsと併用可能
  • TypeScript型推論が強力で、Selectorの型安全性が高い

Redux Toolkitの代替として、中小規模アプリでの採用が加速しています。新規プロジェクトではZustandを第一候補として検討する価値があります。

参考リソース

この技術を体系的に学びたいですか?

未来学では東証プライム上場企業のITエンジニアが24時間サポート。月額24,800円から、退会金0円のオンラインIT塾です。

メールで無料相談する
← 一覧に戻る