T-CREATOR

useStoreフック徹底解説:Zustandの基本操作をマスターしよう

useStoreフック徹底解説:Zustandの基本操作をマスターしよう

React でステート管理を行う際に、シンプルさと柔軟性を兼ね備えたライブラリとして人気を集めている Zustand。その中心となる API がuseStoreフックです。今回は、このフックの使い方を徹底的に解説し、Zustand を使いこなすための知識を身につけていきましょう。

はじめに

Zustand の useStore フックの位置づけ

Zustand は、「bear(クマ)」を意味するドイツ語に由来するシンプルなステート管理ライブラリです。そして、このライブラリの核となるのがuseStoreフックです。

useStoreフックは、Zustand のストアからデータを取得し、コンポーネントで利用するための橋渡し役を担っています。他のステート管理ライブラリと比較して、この部分の API がとてもシンプルで直感的なのが Zustand の大きな特徴です。

typescript// ストアの作成
import { create } from 'zustand';

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

// コンポーネントでの利用
function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore(
    (state) => state.increment
  );

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

この例からわかるように、useCounterStore自体がフックとして機能し、ストアの状態にアクセスするための入り口となります。

Hooks 時代のステート管理の考え方

React Hooks の登場は、React の状態管理に革命をもたらしました。クラスコンポーネントからの脱却により、より関数型的なアプローチが可能になりました。

Zustand は、この Hooks 時代のステート管理の哲学に完全に沿っています。特に以下の点で、モダンな React 開発に適したアプローチを提供しています:

  1. コンポーザビリティ - フックは組み合わせて使用できる
  2. シンプルさ - ボイラープレートが少ない
  3. 関数型 - 純粋関数と不変性を重視
  4. 分離性 - UI とロジックを明確に分離

useStoreフックは、これらの原則に基づいて設計されており、React Hooks のエコシステムにシームレスに統合される形になっています。

背景

グローバルステートとローカルステートの使い分け

React アプリケーションでは、状態(ステート)を大きく 2 つのカテゴリに分けることができます:

  1. ローカルステート - 単一のコンポーネントに閉じた状態
  2. グローバルステート - 複数のコンポーネントで共有される状態

ローカルステートはuseStateuseReducerなどの React 標準フックを使用して管理するのが一般的です。一方、グローバルステートは以下のような特徴を持つデータに適しています:

  • アプリケーションの多くの場所で必要とされるデータ
  • 深いコンポーネント階層を超えて共有される状態
  • ユーザー認証情報、テーマ設定、言語設定などのアプリケーション全体の設定
  • ショッピングカート、通知などのクロスカッティングな関心事

Zustand のuseStoreは、まさにこのグローバルステート管理のために設計されています。ローカルステートは引き続き React の標準フックで管理しつつ、共有が必要な状態は Zustand で管理するという使い分けが効果的です。

React の useState と useStore の関係性

React 標準のuseStateフックと Zustand のuseStoreフックは、表面的には似ているように見えますが、その役割は明確に異なります。

useState:

  • 単一のコンポーネント内でのみ状態を維持する
  • コンポーネントがアンマウントされると状態も失われる
  • 子コンポーネントに状態を渡すには props が必要
typescriptfunction Counter() {
  const [count, setCount] = useState(0);

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

      {/* 子コンポーネントに状態を渡す */}
      <CountDisplay count={count} />
    </div>
  );
}

useStore:

  • コンポーネント間で状態を共有できる
  • コンポーネントのライフサイクルと独立して状態を維持する
  • props のバケツリレーが不要
typescript// ストアを定義
const useCounterStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
}));

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

  return (
    <div>
      <button onClick={increment}>Increment</button>

      {/* 子コンポーネントへの明示的な状態の受け渡しが不要 */}
      <CountDisplay />
    </div>
  );
}

function CountDisplay() {
  // 別のコンポーネントでも同じストアにアクセスできる
  const count = useCounterStore((state) => state.count);

  return <p>Count: {count}</p>;
}

両者を適切に使い分けることで、コンポーネントの結合度を下げつつ、必要な状態共有を実現できます。

課題

コンポーネント間のステート共有の問題

React 開発において、コンポーネント間でステートを共有する方法はいくつかあります:

  1. Props - 単純だが、深いネストでは「props drilling」問題が発生
  2. Context API - Provider のネストが複雑になりがち
  3. Redux - ボイラープレートが多く、小規模アプリでは過剰な場合も
  4. MobX - 学習コストが高め

これらの方法はそれぞれに長所と短所がありますが、特に以下のような課題があります:

  • コード量の増加
  • パフォーマンスへの影響
  • 複雑な状態更新ロジック
  • テスト難易度の上昇

Zustand のuseStoreフックはこれらの課題に対して、シンプルながらも強力な解決策を提供します。

再レンダリング最適化の重要性

React アプリケーションのパフォーマンスにおいて、不必要な再レンダリングを避けることは非常に重要です。特にグローバルステート管理では、状態の変更が多くのコンポーネントの再レンダリングを引き起こす可能性があります。

たとえば、次のような実装では、countが変更されるたびにExpensiveComponentも再レンダリングされてしまいます:

typescript// 非効率な実装
function App() {
  const { count, user } = useGlobalStore();

  return (
    <div>
      <p>Count: {count}</p>
      {/* countの変更でuserを使うコンポーネントも再レンダリングされる */}
      <ExpensiveComponent user={user} />
    </div>
  );
}

Zustand のuseStoreフックは、このような問題を解決するための機能を備えています。具体的には、ストアの一部だけを選択的に購読するセレクタパターンをサポートしており、状態変更の影響範囲を最小限に抑えることができます。

TypeScript との型安全な統合

TypeScript を使用したプロジェクトでは、型安全性を確保することが重要です。グローバルステート管理においても、以下のような型の課題があります:

  • ストア全体の型定義
  • アクションの型定義
  • セレクタ関数の型推論
  • 派生値の型安全性

これらの課題に対して、Zustand は優れた TypeScript サポートを提供しています。

解決策

useStore フックの基本的な使い方

Zustand のuseStoreフックは、非常にシンプルでありながら強力です。基本的な使い方は次のとおりです:

  1. ストアの作成
typescriptimport { create } from 'zustand';

// ストアの型定義(TypeScript使用時)
interface BearState {
  bears: number;
  increase: (by: number) => void;
  reset: () => void;
}

// ストアの作成
const useBearStore = create<BearState>((set) => ({
  bears: 0,
  increase: (by) =>
    set((state) => ({ bears: state.bears + by })),
  reset: () => set({ bears: 0 }),
}));
  1. コンポーネントでの使用
typescriptfunction BearCounter() {
  // ストア全体を購読
  const { bears, increase, reset } = useBearStore();

  return (
    <div>
      <h1>{bears} bears around here...</h1>
      <button onClick={() => increase(1)}>
        Add a bear
      </button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}
  1. セレクタを使用して特定の値のみ購読
typescriptfunction BearCounter() {
  // bearsのみを購読
  const bears = useBearStore((state) => state.bears);

  return <h1>{bears} bears around here...</h1>;
}

function Controls() {
  // アクションのみを購読
  const { increase, reset } = useBearStore((state) => ({
    increase: state.increase,
    reset: state.reset,
  }));

  return (
    <div>
      <button onClick={() => increase(1)}>
        Add a bear
      </button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

このように、Zustand のストアはまずcreate関数で作成し、作成されたカスタムフックを各コンポーネントで使用します。

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

Zustand の大きな特徴の一つが、セレクタを使ったパフォーマンス最適化です。セレクタを使うことで、コンポーネントは必要な状態のみを購読できます。

typescriptfunction BearCounter() {
  // bearsの値のみに関心を持つ
  const bears = useBearStore((state) => state.bears);

  // bearsが変更された場合のみ再レンダリングされる
  return <h1>{bears} bears around here...</h1>;
}

これにより、不必要な再レンダリングを防ぐことができます。例えば、ストア内の他の状態(別のフィールドや関数)が変更されても、このコンポーネントは再レンダリングされません。

さらに、複雑なデータ変換を伴う場合は、useMemoと組み合わせることでさらに最適化できます:

typescriptfunction FilteredItems() {
  const [filter, setFilter] = useState('');
  const items = useItemStore((state) => state.items);

  // フィルタリングロジックをメモ化
  const filteredItems = useMemo(() => {
    return items.filter((item) =>
      item.name.includes(filter)
    );
  }, [items, filter]);

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder='Filter items...'
      />
      <ul>
        {filteredItems.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

shallow 比較と等価性チェックのカスタマイズ

デフォルトでは、Zustand はセレクタの戻り値を===(厳密等価性)で比較します。しかし、オブジェクトや配列を返す場合、この比較方法では再レンダリングを最適化できません。

そこで、Zustand はshallow関数を提供しています:

typescriptimport { create } from 'zustand';
import { shallow } from 'zustand/shallow';

function Component() {
  // オブジェクトを返すセレクタ
  const { name, age } = usePersonStore(
    (state) => ({ name: state.name, age: state.age }),
    shallow // shallow比較を使用
  );

  // nameまたはageが変更された時のみ再レンダリング
  return (
    <div>
      <p>Name: {name}</p>
      <p>Age: {age}</p>
    </div>
  );
}

また、独自の比較関数を渡すこともできます:

typescriptfunction Component() {
  const person = usePersonStore(
    (state) => state.person,
    (prev, next) => {
      // 特定のフィールドのみを比較
      return prev.id === next.id && prev.name === next.name;
      // ageは無視する(ageの変更では再レンダリングしない)
    }
  );

  return (
    <div>
      <p>ID: {person.id}</p>
      <p>Name: {person.name}</p>
      <p>Age: {person.age}</p>{' '}
      {/* ageが変わっても再レンダリングされない */}
    </div>
  );
}

これらの比較方法をカスタマイズすることで、よりきめ細かなパフォーマンス最適化が可能になります。

具体例

基本的なステート取得と更新

シンプルな Todo リストを例に見てみましょう:

typescriptimport { create } from 'zustand';

// Todoの型定義
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

// ストアの型定義
interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  removeTodo: (id: number) => void;
}

// ストアの作成
const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  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
      ),
    })),
  removeTodo: (id) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}));

このストアをコンポーネントで使用する例:

typescriptfunction TodoApp() {
  const todos = useTodoStore((state) => state.todos);
  const addTodo = useTodoStore((state) => state.addTodo);
  const toggleTodo = useTodoStore(
    (state) => state.toggleTodo
  );
  const removeTodo = useTodoStore(
    (state) => state.removeTodo
  );
  const [newTodo, setNewTodo] = useState('');

  const handleAddTodo = () => {
    if (newTodo.trim()) {
      addTodo(newTodo);
      setNewTodo('');
    }
  };

  return (
    <div>
      <h1>Todo List</h1>

      <div>
        <input
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder='What needs to be done?'
        />
        <button onClick={handleAddTodo}>Add Todo</button>
      </div>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type='checkbox'
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span
              style={{
                textDecoration: todo.completed
                  ? 'line-through'
                  : 'none',
              }}
            >
              {todo.text}
            </span>
            <button onClick={() => removeTodo(todo.id)}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

部分的なステート取得とメモ化

大きなストアから必要な部分だけを取得し、パフォーマンスを最適化する例:

typescriptimport { create } from 'zustand';
import { shallow } from 'zustand/shallow';

interface DashboardState {
  user: {
    id: string;
    name: string;
    email: string;
  };
  stats: {
    posts: number;
    followers: number;
    following: number;
  };
  notifications: {
    unread: number;
    messages: Array<{ id: string; text: string }>;
  };
  // 他にも多くのデータがあると想定...
}

const useDashboardStore = create<DashboardState>((set) => ({
  user: {
    id: '1',
    name: 'User',
    email: 'user@example.com',
  },
  stats: { posts: 10, followers: 100, following: 50 },
  notifications: {
    unread: 5,
    messages: [{ id: '1', text: 'New message!' }],
  },
}));

// ユーザープロフィールコンポーネント
function UserProfile() {
  // ユーザー情報だけを購読
  const user = useDashboardStore((state) => state.user);

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// 統計コンポーネント
function Stats() {
  // 統計情報だけを購読(shallowを使用して最適化)
  const stats = useDashboardStore(
    (state) => state.stats,
    shallow
  );

  return (
    <div>
      <p>Posts: {stats.posts}</p>
      <p>Followers: {stats.followers}</p>
      <p>Following: {stats.following}</p>
    </div>
  );
}

// 通知コンポーネント
function Notifications() {
  // 特定のフィールドだけを購読
  const unreadCount = useDashboardStore(
    (state) => state.notifications.unread
  );

  return (
    <div>
      <h3>
        Notifications{' '}
        {unreadCount > 0 && `(${unreadCount})`}
      </h3>
    </div>
  );
}

この例では、各コンポーネントが必要なデータだけを購読しているため、他の部分が更新されても影響を受けません。

非同期処理との連携パターン

API からデータを取得するような非同期処理と Zustand を組み合わせる例:

typescriptinterface UserState {
  users: User[];
  loading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
}

interface User {
  id: number;
  name: string;
  email: string;
}

const useUserStore = create<UserState>((set) => ({
  users: [],
  loading: false,
  error: null,
  fetchUsers: async () => {
    try {
      set({ loading: true, error: null });

      // APIリクエスト
      const response = await fetch(
        'https://jsonplaceholder.typicode.com/users'
      );

      if (!response.ok) {
        throw new Error('Failed to fetch users');
      }

      const users = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({
        error:
          error instanceof Error
            ? error.message
            : 'Unknown error',
        loading: false,
      });
    }
  },
}));

function 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} ({user.email})
        </li>
      ))}
    </ul>
  );
}

このようにすることで、非同期処理の状態もストアで管理でき、ローディング状態やエラー状態を簡単に扱えます。

コンポーネント外でのステートアクセス

Zustand の大きな利点の一つは、React コンポーネント外からでもストアにアクセスできることです:

typescriptimport { create } from 'zustand';

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

const useAuthStore = create<AuthState>((set) => ({
  token: null,
  user: null,
  login: (token, user) => set({ token, user }),
  logout: () => set({ token: null, user: null }),
}));

// APIリクエスト関数(コンポーネント外)
export async function fetchProtectedData() {
  const token = useAuthStore.getState().token;

  if (!token) {
    throw new Error('No authentication token');
  }

  const response = await fetch(
    'https://api.example.com/data',
    {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    }
  );

  return response.json();
}

// 別の例: 認証状態のチェック
export function isAuthenticated() {
  return useAuthStore.getState().token !== null;
}

// ルーター用のガード関数
export function requireAuth(to, from, next) {
  if (isAuthenticated()) {
    next();
  } else {
    next('/login');
  }
}

このように、コンポーネント外のユーティリティ関数やミドルウェアからもストアの状態にアクセスできるため、認証状態の管理や、API リクエストの共通処理などに便利です。

ステートの派生値を計算する方法

ストアの状態から派生する値を効率的に計算する方法:

typescriptinterface CartState {
  items: Array<{
    id: string;
    name: string;
    price: number;
    quantity: number;
  }>;
  addItem: (item: {
    id: string;
    name: string;
    price: number;
  }) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
}

const useCartStore = create<CartState>((set) => ({
  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
      ),
    })),
}));

// 派生値を計算するカスタムフック
function useCartSummary() {
  const items = useCartStore((state) => state.items);

  // useMemoを使用して値をメモ化
  const summary = useMemo(() => {
    return {
      totalItems: items.reduce(
        (sum, item) => sum + item.quantity,
        0
      ),
      totalPrice: items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      ),
      uniqueItems: items.length,
    };
  }, [items]);

  return summary;
}

function CartSummary() {
  const { totalItems, totalPrice, uniqueItems } =
    useCartSummary();

  return (
    <div>
      <h3>Cart Summary</h3>
      <p>Unique items: {uniqueItems}</p>
      <p>Total items: {totalItems}</p>
      <p>Total price: ${totalPrice.toFixed(2)}</p>
    </div>
  );
}

この例では、カートの合計金額や商品数などの派生値をuseMemoを使って効率的に計算しています。

まとめ

useStore フックの使い分け方

Zustand のuseStoreフックを効果的に使うためのパターンをまとめます:

  1. 基本的な使用法

    • 単純な状態取得: const value = useStore(state => state.value)
    • 複数の値の取得: const { a, b, c } = useStore(state => ({ a: state.a, b: state.b, c: state.c }), shallow)
  2. パフォーマンス最適化

    • 必要最小限の状態のみを選択的に購読する
    • オブジェクトを返す場合はshallowまたは独自の比較関数を使用する
    • 頻繁に変更される状態と安定している状態を別々のストアに分割する
  3. コンテキスト別の推奨パターン

    • UI 状態(テーマ、言語など): グローバルな単一ストア
    • ドメインデータ(ユーザー、商品など): 複数の小さなストア
    • フォーム状態: 専用ストアまたはローカルステート
    • 認証状態: 独立したストア(getStateを活用)
  4. コンポーネント設計の考慮事項

    • 再利用可能なコンポーネントには明示的に props を渡す
    • アプリケーション固有のコンポーネントでは直接ストアを使用できる
    • コンテナ/プレゼンテーションパターンを検討する

よくあるアンチパターンと回避策

Zustand を使う際によくある間違いと、その回避方法を紹介します:

  1. ストア全体を購読する

    typescript// ❌ 悪い例 - ストア全体を購読
    const state = useStore();
    

    このアプローチでは、ストア内のどの値が変更されても再レンダリングされます。

    typescript// ✅ 良い例 - 必要な値だけを選択的に購読
    const count = useStore((state) => state.count);
    
  2. コンポーネント内でセレクタをインライン定義する

    typescript// ❌ 悪い例 - レンダリングのたびに新しいセレクタ関数が作られる
    function Component() {
      const user = useStore((state) => state.user);
      // ...
    }
    

    これはほとんどの場合問題ありませんが、複雑なセレクタや厳密な最適化が必要な場合は以下のようにします:

    typescript// ✅ 良い例 - コンポーネント外でセレクタを定義
    const userSelector = (state) => state.user;
    
    function Component() {
      const user = useStore(userSelector);
      // ...
    }
    
  3. 浅いオブジェクトでshallowを使わない

    typescript// ❌ 悪い例 - オブジェクトを返すが比較関数を指定していない
    const { name, age } = useStore((state) => ({
      name: state.name,
      age: state.age,
    }));
    

    この場合、毎回新しいオブジェクトが作成されるため、常に再レンダリングされます。

    typescript// ✅ 良い例 - shallowを使用
    const { name, age } = useStore(
      (state) => ({ name: state.name, age: state.age }),
      shallow
    );
    
  4. 巨大な単一ストアの作成

    typescript// ❌ 悪い例 - すべての状態を1つのストアに詰め込む
    const useStore = create((set) => ({
      user: {
        /* ... */
      },
      products: {
        /* ... */
      },
      cart: {
        /* ... */
      },
      ui: {
        /* ... */
      },
      // などなど...
    }));
    

    これは管理が難しく、パフォーマンスにも影響します。

    typescript// ✅ 良い例 - 論理的な単位でストアを分割
    const useUserStore = create(/* ... */);
    const useProductStore = create(/* ... */);
    const useCartStore = create(/* ... */);
    const useUIStore = create(/* ... */);
    
  5. ミューテーションの使用

    typescript// ❌ 悪い例 - 状態を直接変更
    set((state) => {
      state.count++;
      return state;
    });
    

    これは予期せぬ動作を引き起こす可能性があります。

    typescript// ✅ 良い例 - イミュータブルな更新
    set((state) => ({ count: state.count + 1 }));
    

    または、immer ミドルウェアを使用する:

    typescriptimport { immer } from 'zustand/middleware/immer';
    
    const useStore = create(
      immer((set) => ({
        count: 0,
        increment: () =>
          set((state) => {
            state.count++; // immerを使えば安全
          }),
      }))
    );
    

これらのパターンとアンチパターンを理解することで、より効果的に Zustand を活用できるようになります。

関連リンク