T-CREATOR

Zustand入門:数行で始めるシンプルなグローバルステート管理

Zustand入門:数行で始めるシンプルなグローバルステート管理

React アプリケーションを開発していると、複雑なステート管理に頭を悩ませることがありますよね。今日は、そんな悩みを解決してくれる「Zustand」というライブラリをご紹介します。たった数行のコードでグローバルステート管理を始められる、シンプルで強力なツールです。

Zustand は、ボイラープレートが少なく、使いやすさを重視したステート管理ライブラリです。Redux や Context API と比べて学習コストが低く、すぐに実装できるのが大きな魅力です。さっそく、その魅力に迫っていきましょう!

背景:React におけるステート管理の変遷

React でアプリケーションを開発するとき、複数のコンポーネント間でデータを共有する必要がよくあります。小規模なアプリケーションであれば、props のバケツリレーや Context API で対応できるかもしれません。

しかし、アプリケーションが大きくなるにつれて、状態管理はより複雑になっていきます。その解決策として、長らく Redux が定番のライブラリとして使われてきました。

しかし、Redux には次のような課題もありました:

  1. 学習コストが高い
  2. ボイラープレートコードが多い
  3. Action、Reducer、Selector など多くの概念を理解する必要がある
  4. 設定が複雑で初心者には敷居が高い

そこで登場したのが、よりシンプルなアプローチを提供する Zustand です。

課題:従来のステート管理の問題点

複雑すぎるボイラープレートコード

Redux を使ったことがある方なら、次のようなコードを書いた経験があるでしょう:

typescript// アクションタイプの定義
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

// アクションクリエイター
const incrementAction = () => ({ type: INCREMENT });
const decrementAction = () => ({ type: DECREMENT });

// 初期状態
const initialState = { count: 0 };

// リデューサー
function reducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    default:
      return state;
  }
}

// ストアの作成
const store = createStore(reducer);

これだけのコードを書くだけでも、多くのボイラープレートが必要です。そして、これはまだ単純なカウンターアプリケーションの例です。実際のアプリケーションではもっと複雑になるでしょう。

コンポーネント間のデータ共有の難しさ

Context API を使う場合も、Provider をアプリケーションのルートに配置し、必要なコンポーネントで useContext を使う必要があります。複数の状態がある場合は、それぞれに Provider を作成するか、複雑なコンテキスト構造を設計する必要があります。

typescript// Context APIを使った例
const CountContext = React.createContext(null);

function CountProvider({ children }) {
  const [count, setCount] = useState(0);

  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  );
}

// 使用するコンポーネント
function Counter() {
  const { count, setCount } = useContext(CountContext);

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

// アプリケーションのルート
function App() {
  return (
    <CountProvider>
      <Counter />
      <OtherComponent />
    </CountProvider>
  );
}

これも一見シンプルに見えますが、状態が増えるにつれて Provider が増え、「Provider 地獄」と呼ばれる状況に陥ることもあります。

パフォーマンスの問題

また、Context API は値が変更されるたびに、その Context を消費するすべてのコンポーネントが再レンダリングされる傾向があります。これは大規模なアプリケーションではパフォーマンスの問題を引き起こす可能性があります。

解決策:Zustand の特徴と利点

Zustand は、これらの問題を解決するために設計されたライブラリです。以下の特徴があります:

  1. シンプルな API - ボイラープレートが少なく、直感的に使える
  2. 高いパフォーマンス - 必要なコンポーネントのみ再レンダリング
  3. TypeScript フレンドリー - 型安全なステート管理
  4. 軽量 - バンドルサイズが小さい(約 3KB)
  5. ミドルウェアサポート - persist、devtools、immer などの拡張機能

何よりも、Zustand の最大の魅力は「シンプルさ」です。カウンターアプリケーションを例にとると、Zustand では次のようなコードになります:

typescriptimport create from 'zustand';

// ストアの作成
const useStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
}));

// コンポーネントでの使用
function Counter() {
  const { count, increment, decrement } = useStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}

これだけです!Redux と比べて、コード量が大幅に削減されていることがわかります。アクションタイプ、アクションクリエイター、リデューサーなどの概念を理解する必要もありません。

具体例:Zustand を使ったステート管理の実装

それでは、Zustand を実際に使ってみましょう。まずはインストールから始めます。

インストール

Yarn を使用して Zustand をインストールします:

bashyarn add zustand

基本的なストアの作成

Zustand でストアを作成するには、create関数を使用します。この関数は、状態とその更新関数を含むオブジェクトを返す関数を引数に取ります。

typescriptimport { create } from 'zustand';

// 基本的なストアの作成
const useCountStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

set関数は、現在の状態を部分的に更新するために使用されます。Redux と同様に、状態を直接変更するのではなく、新しい状態オブジェクトを返すことで更新します。

コンポーネントでのストアの使用

作成したストアをコンポーネントで使用するのは非常に簡単です。React Hooks のように、useCountStore フックをコンポーネント内で呼び出すだけです。

typescriptimport React from 'react';
import { useCountStore } from './store';

function Counter() {
  // ストアから状態と関数を取得
  const { count, increment, decrement, reset } =
    useCountStore();

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

必要な状態や関数だけを選択的に取得できるのも、Zustand の優れた点です。これにより、コンポーネントは関連する状態が変更された場合にのみ再レンダリングされます。

TypeScript との統合

Zustand は最初から TypeScript をサポートしています。型安全なストアを作成するには、次のようにします:

typescriptimport { create } from 'zustand';

// ストアの型定義
interface CountState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

// 型付きストアの作成
const useCountStore = create<CountState>((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  decrement: () =>
    set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

これにより、IDE の補完機能が働き、型の誤りを早期に発見できるようになります。

状態の選択的な使用

大きなストアから一部の状態だけを使用したい場合、selector を使って必要な部分だけを選択できます。これにより、不要な再レンダリングを防ぎ、パフォーマンスを向上させることができます。

typescriptfunction CountDisplay() {
  // countのみを取得
  const count = useCountStore((state) => state.count);

  return <div>Current Count: {count}</div>;
}

function CountControls() {
  // 関数のみを取得
  const { increment, decrement, reset } = useCountStore(
    (state) => ({
      increment: state.increment,
      decrement: state.decrement,
      reset: state.reset,
    })
  );

  return (
    <div>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

このように、コンポーネントが必要とする状態や関数だけを選択的に取得することで、パフォーマンスを最適化できます。

非同期アクションの実装

Zustand で非同期アクションを実装するのも簡単です。たとえば、API からデータをフェッチする例を見てみましょう:

typescriptimport { create } from 'zustand';

interface UserState {
  users: { id: number; name: string }[];
  loading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
}

const useUserStore = create<UserState>((set) => ({
  users: [],
  loading: false,
  error: null,
  fetchUsers: async () => {
    try {
      set({ loading: true, error: null });
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/users'
      );
      const users = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

コンポーネントでの使用例:

typescriptfunction UserList() {
  const { users, loading, error, fetchUsers } =
    useUserStore();

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

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

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

Redux のような Thunk middleware 等を導入する必要がなく、シンプルに非同期処理を扱えるのも魅力的です。

ミドルウェアによる拡張性

Zustand は様々なミドルウェアをサポートしており、機能を拡張できます。よく使われるミドルウェアをいくつか紹介します。

永続化(localStorage/sessionStorage)

ブラウザの localStorage や sessionStorage に状態を保存するには、persistミドルウェアを使用します:

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

interface ThemeState {
  darkMode: boolean;
  toggleTheme: () => void;
}

const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      darkMode: false,
      toggleTheme: () =>
        set((state) => ({ darkMode: !state.darkMode })),
    }),
    {
      name: 'theme-storage', // ストレージのキー
      storage: createJSONStorage(() => localStorage), // ストレージタイプ
    }
  )
);

これにより、ページをリロードしても状態が保持されるようになります。

Immer を使った状態の更新

複雑なネストされたオブジェクトを更新する場合、Immer を使うと便利です:

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

interface TodoState {
  todos: { id: number; text: string; completed: boolean }[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
}

const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: [],
    addTodo: (text) =>
      set((state) => {
        state.todos.push({
          id: Date.now(),
          text,
          completed: false,
        });
      }),
    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find(
          (todo) => todo.id === id
        );
        if (todo) {
          todo.completed = !todo.completed;
        }
      }),
  }))
);

Immer を使うと、状態を直接変更しているように書けるため、より直感的なコードになります。

Redux DevTools との連携

デバッグを容易にするために、Redux DevTools と連携することもできます:

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

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

これにより、Redux DevTools で状態の変更を追跡できるようになります。

実用的なユースケース

Zustand は様々なユースケースに対応できます。いくつかの実用的な例を見てみましょう。

認証状態管理

ユーザーの認証状態を管理する例:

typescriptimport { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthState {
  user: { id: string; name: string } | null;
  token: string | null;
  isAuthenticated: boolean;
  login: (
    userData: { id: string; name: string },
    token: string
  ) => void;
  logout: () => void;
}

const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      isAuthenticated: false,
      login: (userData, token) =>
        set({
          user: userData,
          token,
          isAuthenticated: true,
        }),
      logout: () =>
        set({
          user: null,
          token: null,
          isAuthenticated: false,
        }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => localStorage),
    }
  )
);
テーマ切り替え

ダークモードとライトモードを切り替える例:

typescriptimport { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface ThemeState {
  darkMode: boolean;
  toggleTheme: () => void;
  setTheme: (isDark: boolean) => void;
}

const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      darkMode: false,
      toggleTheme: () =>
        set((state) => ({ darkMode: !state.darkMode })),
      setTheme: (isDark) => set({ darkMode: isDark }),
    }),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => localStorage),
    }
  )
);

// コンポーネントでの使用例
function ThemeToggle() {
  const { darkMode, toggleTheme } = useThemeStore();

  useEffect(() => {
    // テーマに応じてボディのクラスを変更
    document.body.classList.toggle('dark-theme', darkMode);
  }, [darkMode]);

  return (
    <button onClick={toggleTheme}>
      {darkMode
        ? '🌞 ライトモードに切り替え'
        : '🌙 ダークモードに切り替え'}
    </button>
  );
}
ショッピングカート

EC サイトのショッピングカート機能:

typescriptimport { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: number) => void;
  updateQuantity: (id: number, quantity: number) => void;
  clearCart: () => void;
  getTotal: () => number;
}

const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],

      addItem: (newItem) =>
        set((state) => {
          const existingItem = state.items.find(
            (item) => item.id === newItem.id
          );

          if (existingItem) {
            return {
              items: state.items.map((item) =>
                item.id === newItem.id
                  ? { ...item, quantity: item.quantity + 1 }
                  : item
              ),
            };
          }

          return {
            items: [
              ...state.items,
              { ...newItem, quantity: 1 },
            ],
          };
        }),

      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter(
            (item) => item.id !== id
          ),
        })),

      updateQuantity: (id, quantity) =>
        set((state) => ({
          items: state.items.map((item) =>
            item.id === id ? { ...item, quantity } : item
          ),
        })),

      clearCart: () => set({ items: [] }),

      getTotal: () => {
        return get().items.reduce(
          (total, item) =>
            total + item.price * item.quantity,
          0
        );
      },
    }),
    {
      name: 'shopping-cart',
      storage: createJSONStorage(() => localStorage),
    }
  )
);

これらの例からわかるように、Zustand は様々なユースケースに柔軟に対応できます。

まとめ:Zustand のメリットと適切な使用シーン

Zustand は、シンプルさと柔軟性を兼ね備えたステート管理ライブラリです。その主なメリットをまとめると:

  1. シンプルな API - 少ないボイラープレートで実装できる
  2. 学習コストが低い - 複雑な概念を理解する必要がない
  3. TypeScript フレンドリー - 型安全なステート管理
  4. 高いパフォーマンス - 必要なコンポーネントのみ再レンダリング
  5. 拡張性 - 様々なミドルウェアで機能拡張可能
  6. 小さなバンドルサイズ - アプリケーションの読み込み時間を短縮

Zustand が特に適している使用シーンは:

  • 中小規模のアプリケーション
  • プロトタイピングや迅速な開発が必要な場合
  • Redux や MobX などの複雑なライブラリを避けたい場合
  • シンプルなグローバル状態が必要な場合

一方で、非常に大規模で複雑なアプリケーションや、厳格なアーキテクチャが必要な場合は、Redux のような成熟したライブラリの方が適している場合もあります。

しかし、多くの場合、Zustand の提供するシンプルさと柔軟性は、開発効率とコードの可読性を大幅に向上させてくれるでしょう。たった数行のコードでグローバルステート管理を始められる手軽さは、多くの開発者に支持されている理由のひとつです。

Zustand は、「必要なことだけをシンプルに」という思想に基づいており、その点が現代の React 開発の流れにマッチしています。ぜひ、次のプロジェクトで Zustand を試してみてください。きっと、状態管理に対する考え方が変わるはずです。

関連リンク