Reactでの状態管理にZustandを使う実践ガイド:軽量&ボイラープレート不要

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Reduxのボイラープレートという悩みの種

数千行を超える規模のReact開発を経験した方なら、コンポーネント間での状態共有の煩わしさを痛感したことがあるはずです。useStateを使い、4〜5層もの中間コンポーネントを経由してバケツリレー(prop drilling)を行うと、コードの可読性が著しく低下しバグの温床になります。かといってContext APIに切り替えると、stateが更新されるたびに配下のコンポーネントツリー全体が一斉に再レンダリングされてしまいます。

そのため、反射的にRedux Toolkitを選択するチームも少なくありません。確かにReduxは堅牢ですが、大量のアクション、リデューサー、セレクター、そしてProviderの設定ファイルが付きまといます。ダークモードの切り替えやログイン情報の保持といったシンプルな機能のためだけに、何百行もの付随コードを書かされる羽目になります。

「ハエを叩くのに大砲を使う」ようなアプローチは、コードベース肥大化の主因です。多くの場合、必要とされるのは軽量で、DOMツリー全体をProviderでラップする必要がなく、必要な箇所だけを的確に再レンダリングしてくれるグローバルストアです。これこそが、最近のプロジェクトで筆者が全面的にZustandへ移行した理由です。

技術的な視点から見るZustandの仕組み

React Three Fiberの作者でもあるPoimandresチームによって開発されたZustandは、React Hooksの仕組みを活かした実用的なアプローチを採用しています:

  • ストアの実体はカスタムフック: すべてのstateと更新アクションを1つの関数に集約できます。データが必要なコンポーネントで、そのフックを呼び出すだけで利用可能です。
  • Context Providerが完全に不要: <App />コンポーネントを<Provider>で囲む必要がありません。コンポーネントツリーがすっきりするだけでなく、親コンテキストへの依存がなくなるためユニットテストも格段に容易になります。
  • セレクターによる最適化された再レンダリング: 登録したセレクターの値が実際に変化した時だけコンポーネントが再レンダリングされます。Context APIで問題となるレンダリングの伝播を完全に回避できます。
  • わずか約1.1KBのバンドルサイズ: 数十KBあるRedux Toolkitと比べて圧倒的に軽量です。

Zustandのステップ別実践ガイド

1. ライブラリのインストール

プロジェクト(ViteまたはNext.js)のターミナルで次のコマンドを実行します:

npm install zustand
# pnpmを使用する場合:
pnpm add zustand

2. ストアの作成

ショッピングカートを管理するためのファイル src/stores/useCartStore.js を作成します。ステートと更新関数が一箇所にすっきりとまとまります:

import { create } from 'zustand';

export const useCartStore = create((set) => ({
  items: [],
  totalCount: 0,

  // 商品追加アクション
  addItem: (product) => set((state) => ({
    items: [...state.items, product],
    totalCount: state.totalCount + 1
  })),

  // カートを空にするアクション
  clearCart: () => set({ items: [], totalCount: 0 })
}));

set関数はルートレベルで自動的に浅いマージ(shallow merge)を行います。初期状態全体をクローンする必要はなく、更新したいフィールドだけを渡せば十分です。

3. コンポーネントでのストア呼び出しと不要な再レンダリングの防止

パフォーマンスを最適化するために、ストア全体を展開するのではなく、必ず必要なセレクターを指定して購読します:

import React from 'react';
import { useCartStore } from '../stores/useCartStore';

// カートバッジ: totalCountが変化した時のみ再レンダリング
export const CartBadge = () => {
  const totalCount = useCartStore((state) => state.totalCount);
  return <span className="badge">カート: {totalCount}</span>;
};

// 商品カード: addItem関数のみを取得するため、totalCountの変化による影響を一切受けない
export const ProductItem = ({ product }) => {
  const addItem = useCartStore((state) => state.addItem);

  return (
    <div className="product-card">
      <h4>{product.name}</h4>
      <p>価格: {product.price.toLocaleString('ja-JP')}円</p>
      <button onClick={() => addItem(product)}>カートに追加</button>
    </div>
  );
};

「カートに追加」をクリックすると、totalCountが増加します。この時、再レンダリングが走るのはCartBadgeのみです。ProductItemはaddItem関数のみを購読しているため、一切再レンダリングされません。

4. 非同期処理(Async Actions)のハンドリング

Redux ThunkやRedux Sagaはもう不要です。Zustandではストア内に直接async関数を定義できます:

export const useUserStore = create((set) => ({
  userData: null,
  isLoading: false,
  error: null,

  fetchUserProfile: async (userId) => {
    set({ isLoading: true, error: null });
    try {
      const res = await fetch(`https://api.example.com/users/${userId}`);
      if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
      const data = await res.json();
      set({ userData: data, isLoading: false });
    } catch (err) {
      set({ error: err.message, isLoading: false });
    }
  }
}));

Tips: APIからのJSONレスポンスを確認したりスキーマをモック化してストアへ組み込む際は、DevToolsを手動で開く代わりにtoolcraft.appのような軽量ツールを使うとスムーズに整形・確認できます。

5. persistミドルウェアによるLocalStorageへの自動永続化

ページをリロードしてもテーマやカートの状態を保持したい場合は、ストアをpersistミドルウェアでラップするだけで完了します:

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

export const useSettingsStore = create(
  persist(
    (set) => ({
      theme: 'light',
      toggleTheme: () => set((state) => ({
        theme: state.theme === 'light' ? 'dark' : 'light'
      }))
    }),
    {
      name: 'app-settings-storage', // localStorageの保存キー名
    }
  )
);

まとめ

Zustandは、重厚なReduxとは一線を画す、非常に快適なコーディング体験をもたらします。Reactフックならではのシンプルな構造を保ちながら、不要な再レンダリングを厳密にコントロールできます。次のプロジェクトでは、ぜひZustandを導入してその軽快さを体感してみてください。

Share: