T-CREATOR

初心者がハマりやすい Zustand のエラーとその解決策まとめ

初心者がハマりやすい Zustand のエラーとその解決策まとめ

Zustand を使い始めた際に「なぜかエラーが出て動かない」「思った通りに動作しない」といった経験はありませんか?本記事では、初心者が特につまずきやすい Zustand のエラーパターンを体系的に整理し、実際のエラーメッセージとともに具体的な解決策をご紹介します。

エラーに遭遇した際の診断方法から予防策まで、実践的なデバッグテクニックを身につけて、スムーズな Zustand 開発を実現しましょう。

背景

初心者がエラーに遭遇しやすい理由

Zustand はシンプルで直感的な API を提供する状態管理ライブラリですが、その手軽さゆえに初心者が見落としがちな落とし穴が存在します。

特に以下のような特性により、予期しないエラーに遭遇することがあります。

#特性初心者への影響
1最小限の設定で動作内部動作の理解不足によるミス
2TypeScript との高い親和性型定義の誤りが複雑なエラーを生む
3React の仕組みに依存React のライフサイクルとの競合
4柔軟なカスタマイズ性不適切な設計によるパフォーマンス問題
typescript// よくある初心者の実装例
const useStore = create((set) => ({
  count: 0,
  // ❌ 型情報が不足している
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
}));

Zustand 特有のエラーパターン

他の状態管理ライブラリと比較して、Zustand では以下のようなエラーパターンが特徴的です。

Redux との違い Redux では action と reducer の分離により、エラーの発生箇所が明確になりやすいのに対し、Zustand ではストア内で直接状態を更新するため、エラーの原因特定が困難になることがあります。

typescript// Redux スタイル(エラー箇所が明確)
const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 }; // ここでエラーが発生
  }
};

// Zustand スタイル(エラー箇所が不明確になりがち)
const useStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({
      count: state.count + 1, // エラーがここなのか、setの呼び出しなのか判断しづらい
    })),
}));

課題

エラー解決の難しさ

Zustand でのエラー解決が困難な理由として、以下の要因が挙げられます。

1. エラーメッセージの抽象性 多くの場合、Zustand 由来のエラーは React や TypeScript のエラーとして表示されるため、根本原因の特定が困難です。

bash# よく見るエラーメッセージの例
TypeError: Cannot read properties of undefined (reading 'count')
Type '(set: SetState<StoreState>) => StoreState' is not assignable to type 'StateCreator<StoreState, [], [], StoreState>'

2. 暗黙的な依存関係 Zustand は React の仕組みに深く依存しているため、React の理解不足がエラーの原因となることがあります。

3. デバッグツールの活用不足 Redux DevTools との連携方法や、適切なデバッグ手法を知らないことで、問題の特定に時間がかかります。

ドキュメント不足の問題

公式ドキュメントは基本的な使用方法に焦点を当てており、エラーハンドリングやトラブルシューティングの情報が限定的です。特に日本語での情報は少なく、初心者にとって解決策を見つけることが困難な状況です。

typescript// ドキュメントには載っていない、よくあるエラーケース
const useStore = create((set) => ({
  data: null,
  fetchData: async () => {
    // ❌ 非同期処理中にコンポーネントがアンマウントされるとエラー
    const response = await api.getData();
    set({ data: response });
  },
}));

解決策

コンパイルエラー編

TypeScript 型エラー

TypeScript を使用している場合に最も頻繁に遭遇するエラーパターンです。

エラー例 1: インターフェース定義の不整合

bashType '(set: SetState<unknown>) => { count: number; increment: () => void; }' is not assignable to type 'StateCreator<StoreState, [], [], StoreState>'.

原因と解決策:

typescript// ❌ 問題のあるコード
interface StoreState {
  count: number;
  increment: () => void;
}

const useStore = create<StoreState>((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
  // ❌ インターフェースにない不正なプロパティ
  extraProperty: 'invalid',
}));

// ✅ 修正版
interface StoreState {
  count: number;
  increment: () => void;
}

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

エラー例 2: ジェネリクス型の指定ミス

bashArgument of type '(set: SetState<StoreState>) => StoreState' is not assignable to parameter of type 'StateCreator<StoreState, [], [], StoreState>'.

解決策:

typescript// ❌ 問題のあるコード
const useStore = create<StoreState>((set, get) => ({
  count: 0,
  doubleCount: () => {
    // ❌ get() の型が推論されない
    return get().count * 2;
  },
}));

// ✅ 修正版
const useStore = create<StoreState>()((set, get) => ({
  count: 0,
  doubleCount: () => {
    // ✅ 正しく型推論される
    return get().count * 2;
  },
}));

インポートエラー

エラー例:モジュール解決の失敗

bashModule '"zustand"' has no exported member 'create'.
Cannot find module 'zustand' or its type declarations.

解決策:

typescript// ❌ 間違ったインポート
import { create } from 'zustand';

// ✅ 正しいインポート(v4以降)
import { create } from 'zustand';

// ✅ v3以前の場合
import create from 'zustand';

バージョンの確認とインストールの実行:

bash# パッケージバージョンの確認
yarn list zustand

# 最新版へのアップデート
yarn upgrade zustand

# 型定義の確認
yarn add -D @types/react

中間件との型エラー

エラー例:devtools との連携

bashType 'StateCreator<StoreState, [["zustand/devtools", never]], [], StoreState>' is not assignable to type 'StateCreator<StoreState, [], [], StoreState>'.

解決策:

typescript// ❌ 問題のあるコード
import { devtools } from 'zustand/middleware';

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

// ✅ 修正版
import { devtools } from 'zustand/middleware';

interface StoreState {
  count: number;
  increment: () => void;
}

const useStore = create<StoreState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set((state) => ({ count: state.count + 1 })),
    }),
    {
      name: 'counter-store', // devtools での表示名
    }
  )
);

ランタイムエラー編

無限ループエラー

エラー例:

bashMaximum update depth exceeded. This can happen when a component repeatedly calls setState inside useEffect, or when a component calls setState inside its render function.

原因と解決策:

typescript// ❌ 問題のあるコード(無限ループを引き起こす)
const useStore = create((set) => ({
  count: 0,
  increment: () => {
    // ❌ set内でsetを呼び出している
    set((state) => {
      set({ count: state.count + 1 });
      return state;
    });
  },
}));

// コンポーネント内での使用
const Component = () => {
  const { count, increment } = useStore();

  // ❌ レンダリング中にstateを更新
  increment();

  return <div>{count}</div>;
};

// ✅ 修正版
const useStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
}));

const Component = () => {
  const { count, increment } = useStore();

  // ✅ イベントハンドラ内で更新
  const handleClick = () => {
    increment();
  };

  return <button onClick={handleClick}>{count}</button>;
};

メモリリーク

症状: ページ遷移後もストアが残り続け、メモリ使用量が増加していく

原因と解決策:

typescript// ❌ 問題のあるコード
const useStore = create((set) => ({
  timer: null as NodeJS.Timeout | null,
  startTimer: () => {
    // ❌ タイマーをクリアせずに新しいタイマーを開始
    const timer = setInterval(() => {
      console.log('Timer running...');
    }, 1000);
    set({ timer });
  },
  stopTimer: () => {
    // ❌ タイマーの解放処理が不完全
    set({ timer: null });
  },
}));

// ✅ 修正版
const useStore = create((set, get) => ({
  timer: null as NodeJS.Timeout | null,
  startTimer: () => {
    // ✅ 既存のタイマーをクリア
    const { timer } = get();
    if (timer) {
      clearInterval(timer);
    }

    const newTimer = setInterval(() => {
      console.log('Timer running...');
    }, 1000);
    set({ timer: newTimer });
  },
  stopTimer: () => {
    // ✅ 適切なクリーンアップ
    const { timer } = get();
    if (timer) {
      clearInterval(timer);
      set({ timer: null });
    }
  },
}));

// コンポーネントでのクリーンアップ
const Component = () => {
  const { startTimer, stopTimer } = useStore();

  useEffect(() => {
    startTimer();

    // ✅ コンポーネントのアンマウント時にクリーンアップ
    return () => {
      stopTimer();
    };
  }, []);

  return <div>Timer Component</div>;
};

状態更新の失敗

エラー例:

bashCannot read properties of null (reading 'someProperty')
TypeError: state.data is undefined

原因と解決策:

typescript// ❌ 問題のあるコード
const useStore = create((set) => ({
  user: null,
  updateUserName: (name) => {
    // ❌ null チェックなしでプロパティにアクセス
    set((state) => ({
      user: {
        ...state.user,
        name: name,
      },
    }));
  },
}));

// ✅ 修正版
interface User {
  id: string;
  name: string;
  email: string;
}

interface StoreState {
  user: User | null;
  updateUserName: (name: string) => void;
  resetUser: () => void;
}

const useStore = create<StoreState>((set) => ({
  user: null,
  updateUserName: (name) => {
    set((state) => {
      // ✅ null チェックを実装
      if (!state.user) {
        console.warn(
          'User is not set. Cannot update name.'
        );
        return state;
      }

      return {
        user: {
          ...state.user,
          name: name,
        },
      };
    });
  },
  resetUser: () => set({ user: null }),
}));

パフォーマンス問題編

不要な再レンダリング

症状: コンポーネントが予期せず頻繁に再レンダリングされる

原因と解決策:

typescript// ❌ 問題のあるコード
const Component = () => {
  // ❌ ストア全体を取得するため、関係ない更新でも再レンダリング
  const store = useStore();

  return <div>{store.count}</div>;
};

// ✅ 修正版 1: 必要な値のみを選択
const Component = () => {
  const count = useStore((state) => state.count);

  return <div>{count}</div>;
};

// ✅ 修正版 2: 複数の値を効率的に取得
const Component = () => {
  const { count, isLoading } = useStore(
    (state) => ({
      count: state.count,
      isLoading: state.isLoading,
    }),
    shallow // shallow比較を使用
  );

  return <div>{isLoading ? 'Loading...' : count}</div>;
};

セレクタの誤用

問題のあるセレクタの例:

typescript// ❌ 問題のあるコード
const Component = () => {
  // ❌ 毎回新しいオブジェクトを返すため、常に再レンダリング
  const derivedData = useStore((state) => ({
    doubled: state.count * 2,
    isEven: state.count % 2 === 0,
  }));

  return <div>{derivedData.doubled}</div>;
};

// ✅ 修正版 1: useMemo を活用
const Component = () => {
  const count = useStore((state) => state.count);

  const derivedData = useMemo(
    () => ({
      doubled: count * 2,
      isEven: count % 2 === 0,
    }),
    [count]
  );

  return <div>{derivedData.doubled}</div>;
};

// ✅ 修正版 2: ストア内で計算済みの値を提供
const useStore = create((set, get) => ({
  count: 0,
  get doubled() {
    return get().count * 2;
  },
  get isEven() {
    return get().count % 2 === 0;
  },
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
}));

shallow 比較の問題

エラー例:

bashWarning: Maximum update depth exceeded

原因と解決策:

typescriptimport { shallow } from 'zustand/shallow';

// ❌ 問題のあるコード
const Component = () => {
  // ❌ shallow を使わずに複数の値を取得
  const { users, filters } = useStore((state) => ({
    users: state.users,
    filters: state.filters,
  }));

  // users や filters が更新されるたびに新しいオブジェクトが生成され、
  // 関連するコンポーネントが不要に再レンダリングされる

  return <UserList users={users} filters={filters} />;
};

// ✅ 修正版
const Component = () => {
  const { users, filters } = useStore(
    (state) => ({
      users: state.users,
      filters: state.filters,
    }),
    shallow // shallow比較により、実際の値が変更された場合のみ再レンダリング
  );

  return <UserList users={users} filters={filters} />;
};

設計・実装ミス編

アンチパターン

1. ストアの過度な分割

typescript// ❌ アンチパターン:関連する状態を不必要に分割
const useUserStore = create((set) => ({
  name: '',
  setName: (name) => set({ name }),
}));

const useUserEmailStore = create((set) => ({
  email: '',
  setEmail: (email) => set({ email }),
}));

const useUserAgeStore = create((set) => ({
  age: 0,
  setAge: (age) => set({ age }),
}));

// ✅ 改善版:関連する状態をまとめて管理
const useUserStore = create((set) => ({
  profile: {
    name: '',
    email: '',
    age: 0,
  },
  updateProfile: (updates) =>
    set((state) => ({
      profile: { ...state.profile, ...updates },
    })),
}));

2. 状態とロジックの混在

typescript// ❌ アンチパターン:ビジネスロジックがストアに混在
const useStore = create((set, get) => ({
  products: [],
  cart: [],
  user: null,

  // ❌ 複雑なビジネスロジックがストア内に
  processCheckout: async () => {
    const { cart, user } = get();

    // 長大な処理...
    if (!user) throw new Error('User not logged in');
    if (cart.length === 0) throw new Error('Cart is empty');

    const total = cart.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    const tax = total * 0.1;

    try {
      const response = await api.processPayment({
        userId: user.id,
        items: cart,
        total: total + tax,
      });

      if (response.success) {
        set({ cart: [] });
        // さらに長い処理...
      }
    } catch (error) {
      // エラーハンドリング...
    }
  },
}));

// ✅ 改善版:ビジネスロジックを別の層に分離
class CheckoutService {
  static async processCheckout(cart, user) {
    if (!user) throw new Error('User not logged in');
    if (cart.length === 0) throw new Error('Cart is empty');

    const total = cart.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    const tax = total * 0.1;

    return await api.processPayment({
      userId: user.id,
      items: cart,
      total: total + tax,
    });
  }
}

const useStore = create((set, get) => ({
  products: [],
  cart: [],
  user: null,
  isProcessingCheckout: false,

  processCheckout: async () => {
    set({ isProcessingCheckout: true });

    try {
      const { cart, user } = get();
      await CheckoutService.processCheckout(cart, user);
      set({ cart: [], isProcessingCheckout: false });
    } catch (error) {
      set({ isProcessingCheckout: false });
      throw error;
    }
  },
}));

状態の分散

問題:関連する状態が複数のストアに分散している

typescript// ❌ 問題のあるコード:関連する状態が分散
const useAuthStore = create((set) => ({
  isAuthenticated: false,
  login: () => set({ isAuthenticated: true }),
}));

const useUserStore = create((set) => ({
  userData: null,
  setUserData: (data) => set({ userData: data }),
}));

const usePermissionStore = create((set) => ({
  permissions: [],
  setPermissions: (perms) => set({ permissions: perms }),
}));

// コンポーネントで複数のストアを監視する必要がある
const Header = () => {
  const isAuthenticated = useAuthStore(
    (state) => state.isAuthenticated
  );
  const userData = useUserStore((state) => state.userData);
  const permissions = usePermissionStore(
    (state) => state.permissions
  );

  // 3つのストアの同期を手動で管理する必要がある
  return <div>...</div>;
};

// ✅ 改善版:関連する状態をまとめて管理
interface AuthState {
  isAuthenticated: boolean;
  userData: UserData | null;
  permissions: Permission[];
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
}

const useAuthStore = create<AuthState>((set) => ({
  isAuthenticated: false,
  userData: null,
  permissions: [],

  login: async (credentials) => {
    try {
      const response = await authAPI.login(credentials);
      set({
        isAuthenticated: true,
        userData: response.user,
        permissions: response.permissions,
      });
    } catch (error) {
      throw error;
    }
  },

  logout: () => {
    set({
      isAuthenticated: false,
      userData: null,
      permissions: [],
    });
  },
}));

適切でない初期化

問題:非同期初期化の不適切な実装

typescript// ❌ 問題のあるコード
const useStore = create((set) => {
  // ❌ 作成時に非同期処理を実行(エラーハンドリングなし)
  loadInitialData().then((data) => {
    set({ data });
  });

  return {
    data: null,
    isLoading: true, // ❌ 初期化の完了を追跡できない
  };
});

// ✅ 改善版
interface StoreState {
  data: any | null;
  isLoading: boolean;
  error: string | null;
  initialize: () => Promise<void>;
}

const useStore = create<StoreState>((set) => ({
  data: null,
  isLoading: false,
  error: null,

  initialize: async () => {
    set({ isLoading: true, error: null });

    try {
      const data = await loadInitialData();
      set({ data, isLoading: false });
    } catch (error) {
      set({
        error: error.message,
        isLoading: false,
      });
    }
  },
}));

// コンポーネントでの適切な初期化
const App = () => {
  const { initialize, isLoading, error } = useStore();

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

  if (isLoading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;

  return <MainContent />;
};

具体例

実際のエラーメッセージと解決手順

ケース 1: セレクタ関数での TypeScript エラー

エラーメッセージ:

bashArgument of type '(state: unknown) => any' is not assignable to parameter of type '(state: StoreState) => any'.
  Parameter 'state' is implicitly has an 'any' type.

問題のコード:

typescriptinterface StoreState {
  count: number;
  name: string;
}

const useStore = create<StoreState>((set) => ({
  count: 0,
  name: '',
  updateCount: (value) => set({ count: value }),
}));

// ❌ 型推論が働かない
const Component = () => {
  const count = useStore((state) => state.count);
  return <div>{count}</div>;
};

解決手順:

  1. 型定義の確認
typescript// ストアの型定義を明示的に指定
const useStore = create<StoreState>()((set) => ({
  count: 0,
  name: '',
  updateCount: (value: number) => set({ count: value }),
}));
  1. セレクタの型指定
typescriptconst Component = () => {
  const count = useStore(
    (state: StoreState) => state.count
  );
  return <div>{count}</div>;
};
  1. 型安全なカスタムフック(推奨)
typescriptconst useStoreCount = () =>
  useStore((state) => state.count);

const Component = () => {
  const count = useStoreCount();
  return <div>{count}</div>;
};

ケース 2: 非同期処理での状態更新エラー

エラーメッセージ:

bashWarning: Can't perform a React state update on an unmounted component.

問題のコード:

typescriptconst useStore = create((set) => ({
  data: null,
  loading: false,

  fetchData: async () => {
    set({ loading: true });

    try {
      // ❌ コンポーネントがアンマウントされた後に実行される可能性
      const response = await api.getData();
      set({ data: response, loading: false });
    } catch (error) {
      set({ loading: false });
    }
  },
}));

解決手順:

  1. AbortController の導入
typescriptconst useStore = create((set, get) => ({
  data: null,
  loading: false,
  abortController: null,

  fetchData: async () => {
    // 既存のリクエストをキャンセル
    const currentController = get().abortController;
    if (currentController) {
      currentController.abort();
    }

    const newController = new AbortController();
    set({ loading: true, abortController: newController });

    try {
      const response = await api.getData({
        signal: newController.signal,
      });

      // ✅ キャンセルされていない場合のみ状態を更新
      if (!newController.signal.aborted) {
        set({ data: response, loading: false });
      }
    } catch (error) {
      if (!newController.signal.aborted) {
        set({ loading: false });
      }
    }
  },

  cleanup: () => {
    const controller = get().abortController;
    if (controller) {
      controller.abort();
    }
  },
}));
  1. コンポーネントでのクリーンアップ
typescriptconst DataComponent = () => {
  const { fetchData, cleanup, data, loading } = useStore();

  useEffect(() => {
    fetchData();

    // ✅ アンマウント時にクリーンアップ
    return () => {
      cleanup();
    };
  }, []);

  if (loading) return <div>読み込み中...</div>;
  return <div>{JSON.stringify(data)}</div>;
};

ケース 3: パフォーマンス問題の解決

症状:リストコンポーネントの重い再レンダリング

問題のコード:

typescriptconst useStore = create((set) => ({
  items: [],
  selectedId: null,

  addItem: (item) =>
    set((state) => ({
      items: [...state.items, item],
    })),

  selectItem: (id) => set({ selectedId: id }),
}));

// ❌ 全てのアイテムが再レンダリングされる
const ItemList = () => {
  const { items, selectedId } = useStore();

  return (
    <div>
      {items.map((item) => (
        <ItemComponent
          key={item.id}
          item={item}
          isSelected={selectedId === item.id}
        />
      ))}
    </div>
  );
};

解決手順:

  1. メモ化の導入
typescriptconst ItemComponent = React.memo(({ item, isSelected }) => {
  console.log(`Rendering item ${item.id}`);

  return (
    <div className={isSelected ? 'selected' : ''}>
      {item.name}
    </div>
  );
});
  1. セレクタの最適化
typescriptconst useItem = (itemId) => {
  return useStore(
    (state) => ({
      item: state.items.find((item) => item.id === itemId),
      isSelected: state.selectedId === itemId,
    }),
    shallow
  );
};

const ItemComponent = ({ itemId }) => {
  const { item, isSelected } = useItem(itemId);

  if (!item) return null;

  return (
    <div className={isSelected ? 'selected' : ''}>
      {item.name}
    </div>
  );
};

const ItemList = () => {
  const items = useStore((state) => state.items);

  return (
    <div>
      {items.map((item) => (
        <ItemComponent key={item.id} itemId={item.id} />
      ))}
    </div>
  );
};
  1. ストア設計の見直し
typescript// アイテムをIDでインデックス化
const useStore = create((set) => ({
  itemsById: {},
  itemIds: [],
  selectedId: null,

  addItem: (item) =>
    set((state) => ({
      itemsById: { ...state.itemsById, [item.id]: item },
      itemIds: [...state.itemIds, item.id],
    })),

  updateItem: (id, updates) =>
    set((state) => ({
      itemsById: {
        ...state.itemsById,
        [id]: { ...state.itemsById[id], ...updates },
      },
    })),

  selectItem: (id) => set({ selectedId: id }),
}));

まとめ

Zustand でのエラー解決は、エラーの種類を正しく分類し、それぞれに適した対処法を適用することが重要です。本記事でご紹介した内容をまとめると、以下のようになります。

エラー予防のベストプラクティス

#カテゴリベストプラクティス
1型安全性TypeScript の厳密な型定義とジェネリクス活用
2パフォーマンス適切なセレクタと shallow 比較の使用
3非同期処理AbortController とクリーンアップの実装
4ストア設計責任の分離と適切な粒度での状態管理
5デバッグRedux DevTools の活用と段階的なテスト実装

開発時のチェックポイント

1. 実装前のチェック

  • ストアの責任範囲は適切か?
  • 型定義は十分に厳密か?
  • 非同期処理のライフサイクルを考慮しているか?

2. 実装中のチェック

  • セレクタは最小限の値のみを取得しているか?
  • 不要な再レンダリングが発生していないか?
  • エラーハンドリングは適切に実装されているか?

3. テスト・デバッグ時のチェック

  • Redux DevTools でストアの状態変化を確認
  • React Developer Tools でレンダリング回数をチェック
  • メモリリークがないかパフォーマンス監視

効果的なデバッグツール

typescript// DevTools の設定
const useStore = create<StoreState>()(
  devtools(
    (set) => ({
      // ストア実装
    }),
    {
      name: 'my-store',
      trace: true, // スタックトレースを表示
      serialize: true, // シリアライゼーションを有効化
    }
  )
);

// ログミドルウェアの実装
const logMiddleware = (config) => (set, get, api) =>
  config(
    (...args) => {
      console.log('  applying', args);
      set(...args);
      console.log('  new state', get());
    },
    get,
    api
  );

const useStore = create(
  logMiddleware((set) => ({
    // ストア実装
  }))
);

これらのベストプラクティスを実践することで、Zustand でのエラーを効果的に予防し、発生した場合も迅速に解決できるようになります。エラーに遭遇した際は、まずエラーの分類を行い、本記事で紹介した解決パターンを参考に対処してみてください。

関連リンク