Zustand のレガシーな状態管理からの移行ステップ
React アプリケーションの状態管理は、開発者にとって常に大きな課題です。Redux、Context API、MobX など、様々なライブラリが存在する中で、Zustand はそのシンプルさとパフォーマンスの高さから注目を集めています。
しかし、既存のプロジェクトでレガシーな状態管理を使用している場合、移行は決して簡単ではありません。この記事では、実際のプロジェクトで直面する課題と、段階的に Zustand に移行するための具体的な手順をご紹介します。
レガシー状態管理の課題と Zustand の利点
レガシー状態管理が抱える問題
多くの React プロジェクトでは、以下のような状態管理パターンが使用されています:
Redux の場合:
- ボイラープレートコードが多すぎる
 - 学習コストが高い
 - 小さな状態変更でも多くのファイルを編集する必要がある
 
Context API の場合:
- パフォーマンスの問題(不要な再レンダリング)
 - Provider 地獄の発生
 - 型安全性の欠如
 
MobX の場合:
- 複雑な概念(observable、action、computed)
 - デバッグの困難さ
 - バンドルサイズの肥大化
 
Zustand が解決する課題
Zustand は、これらの問題を以下のように解決します:
typescript// 従来のReduxの場合
// actions.js
export const increment = () => ({
  type:INCREMENT'
});
// reducer.js
const counterReducer = (state =0ion) => {
  switch (action.type) {
    case INCREMENT':
      return state + 1;
    default:
      return state;
  }
};
// コンポーネント
const mapStateToProps = (state) => ({
  count: state.counter
});
const mapDispatchToProps = { increment };
export default connect(mapStateToProps, mapDispatchToProps)(Counter);
typescript// Zustandの場合
import { create } fromzustand';
const useCounterStore = create((set) => ({
  count: 0  increment: () => set((state) => ({ count: state.count + 1 })),
}));
// コンポーネント
const Counter = () => {
  const [object Object]count, increment } = useCounterStore();
  return <button onClick={increment}>{count}</button>;
};
この違いを見ると、Zustand がいかにシンプルで直感的かがわかります。ボイラープレートコードが大幅に削減され、開発効率が向上します。
移行前の準備と環境構築
現在の状態管理の把握
移行を始める前に、現在のプロジェクトの状態管理を詳しく分析する必要があります。
bash# プロジェクトの依存関係を確認
yarn list | grep -E "(redux|mobx|context)
# 状態管理関連のファイルを検索
find src -name*.js" -o -name*.ts-o -name "*.tsx" | xargs grep -l "useState\|useReducer\|useContext\|connect\|Provider
Zustand のインストール
bash# Zustandのインストール
yarn add zustand
# TypeScriptを使用している場合
yarn add -D @types/zustand
開発ツールの設定
Zustand DevTools を設定することで、状態の変化を可視化できます:
typescriptimport { create } fromzustand';
import[object Object] devtools } from 'zustand/middleware;
const useStore = create(
  devtools(
    (set) => ([object Object]     // ストアの定義
    }),
   [object Object]   name: my-store', // DevToolsで表示される名前
    }
  )
);
段階的移行の実践手順
ステップ 1 既存状態の分析とマッピング
まず、現在の状態管理を詳しく分析し、Zustand ストアへの移行計画を立てます。
状態の可視化ツールの作成:
typescript// 既存の状態を分析するためのヘルパー関数
const analyzeExistingState = () => {
  // Reduxの場合
  const reduxState = store.getState();
  console.log('Redux State Structure:', reduxState);
  // Context APIの場合
  const contextValues = document.querySelectorAll('data-context]');
  console.log(Context Values:', contextValues);
  // 状態の依存関係をマッピング
  const stateDependencies = [object Object]    user:auth, ile', 'preferences'],
    cart: ['products,pricing'],
    ui: ['theme,sidebar,modals']
  };
  return stateDependencies;
};
よくあるエラーと解決方法:
typescript// エラー: Cannot read property 'getStateof undefined
// 原因: Reduxストアが正しく初期化されていない
const store = createStore(reducer, initialState);
// エラー: Provider value is undefined
// 原因: Contextの値が正しく提供されていない
const MyProvider = ({ children }) =>[object Object] constvalue, setValue] = useState(initialValue);
  return (
    <MyContext.Provider value={{ value, setValue }}>
     [object Object]children}
    </MyContext.Provider>
  );
};
ステップ 2: Zustand ストアの設計と実装
既存の状態を分析した結果に基づいて、Zustand ストアを設計します。
基本的なストア構造:
typescriptimport { create } fromzustand';
import { persist } from 'zustand/middleware;
// ユーザー関連のストア
interface UserState {
  user: User | null;
  isAuthenticated: boolean;
  login: (credentials: LoginCredentials) => Promise<void>;
  logout: () => void;
  updateProfile: (profile: Partial<User>) => void;
}
const useUserStore = create<UserState>()(
  persist(
    (set, get) => ([object Object]      user: null,
      isAuthenticated: false,
      login: async (credentials) => {
        try[object Object]        const user = await authService.login(credentials);
          set({ user, isAuthenticated: true });
        } catch (error) {
          console.error('Login failed:', error);
          throw error;
        }
      },
      logout: () => {
        set({ user: null, isAuthenticated: false });
      },
      updateProfile: (profile) => {
        const currentUser = get().user;
        if (currentUser)[object Object]        set({ user: { ...currentUser, ...profile } });
        }
      },
    }),
 [object Object]
      name: 'user-storage',
      partialize: (state) => ([object Object] user: state.user, isAuthenticated: state.isAuthenticated }),
    }
  )
);
複雑な状態の分割:
typescript// 大きなストアを複数の小さなストアに分割
const useCartStore = create<CartState>()((set, get) => ({
  items: [],
  total: 0 addItem: (product) => {
    const { items } = get();
    const existingItem = items.find(item => item.id === product.id);
    if (existingItem) {
      set({
        items: items.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity +1            : item
        )
      });
    } else {
      set([object Object] items: [...items, { ...product, quantity: 1 }] });
    }
    // 合計を再計算
    get().calculateTotal();
  },
  calculateTotal: () => {
    const { items } = get();
    const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
    set({ total });
  },
}));
ステップ 3 コンポーネントの段階的置き換え
既存のコンポーネントを段階的に Zustand に移行します。
Redux から Zustand への移行例:
typescript// 移行前(Redux)
import { connect } from 'react-redux';
import { increment, decrement } from './actions';
const Counter = ([object Object] count, increment, decrement }) => (
  <div>
    <span>{count}</span>
    <button onClick={increment}>+</button>
    <button onClick={decrement}>-</button>
  </div>
);
const mapStateToProps = (state) => ({
  count: state.counter.count
});
const mapDispatchToProps = { increment, decrement };
export default connect(mapStateToProps, mapDispatchToProps)(Counter);
typescript// 移行後(Zustand)
import[object Object] useCounterStore } from./stores/counterStore';
const Counter = () => {
  const [object Object] count, increment, decrement } = useCounterStore();
  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
};
export default Counter;
Context API から Zustand への移行例:
typescript// 移行前(Context API)
import { useContext } fromreact';
import { ThemeContext } from './ThemeContext';
const ThemedButton = () => {
  const { theme, toggleTheme } = useContext(ThemeContext);
  return (
    <button
      onClick={toggleTheme}
      className={`btn btn-${theme}`}
    >
      Toggle Theme
    </button>
  );
};
typescript// 移行後(Zustand)
import { useThemeStore } from './stores/themeStore';
const ThemedButton = () => {
  const { theme, toggleTheme } = useThemeStore();
  return (
    <button
      onClick={toggleTheme}
      className={`btn btn-${theme}`}
    >
      Toggle Theme
    </button>
  );
};
よくある移行エラーと解決方法:
typescript// エラー: Cannot read properties of undefined (reading user)
// 原因: ストアの初期化が完了する前にアクセスしている
const UserProfile = () => {
  const user = useUserStore((state) => state.user);
  // 初期化チェックを追加
  if (!user) {
    return <div>Loading...</div>;
  }
  return <div>{user.name}</div>;
};
// エラー: Maximum update depth exceeded
// 原因: ストアの更新が無限ループを引き起こしている
const useCounterStore = create((set) => ({
  count: 0  increment: () => set((state) => ({ count: state.count + 1/ 間違った例: increment: () => set({ count: get().count + 1 })
}));
ステップ 4 テストと検証
移行後の動作を確認し、問題がないことを検証します。
ストアのテスト:
typescriptimport[object Object] renderHook, act } from@testing-library/react';
import[object Object] useCounterStore } from./stores/counterStore';
describe('Counter Store', () =>[object Object]  beforeEach(() => [object Object]
    // 各テスト前にストアをリセット
    useCounterStore.setState({ count: 0 });
  });
  test('should increment count', () => {
    const { result } = renderHook(() => useCounterStore());
    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(1);
  });
  test('should handle async operations', async () => {
    const { result } = renderHook(() => useUserStore());
    await act(async () => {
      await result.current.login({ email:test@example.com', password: password' });
    });
    expect(result.current.isAuthenticated).toBe(true);
  });
});
統合テスト:
typescriptimport { render, screen, fireEvent } from@testing-library/react';
import Counter from ./Counter';
test(Counter component works with Zustand store', () => {
  render(<Counter />);
  const countDisplay = screen.getByText('0');
  const incrementButton = screen.getByText('+);
  fireEvent.click(incrementButton);
  expect(countDisplay).toHaveTextContent(1);
});
移行時の注意点とベストプラクティス
段階的移行の重要性
一度にすべてを移行しようとすると、予期しない問題が発生する可能性があります。以下の順序で進めることをお勧めします:
- 非クリティカルな機能から開始 2 依存関係の少ないコンポーネントを優先 3 テストカバレッジの高い部分から着手
 
型安全性の確保
TypeScript を使用している場合、型定義を適切に行うことが重要です:
typescript// ストアの型定義
interface AppState {
  user: UserState;
  cart: CartState;
  ui: UIState;
}
// セレクターの型安全性
const useUser = () => useUserStore((state) => state.user);
const useCartItems = () =>
  useCartStore((state) => state.items);
// 型安全なアクション
const useUserActions = () =>
  useUserStore((state) => ({
    login: state.login,
    logout: state.logout,
    updateProfile: state.updateProfile,
  }));
パフォーマンスの最適化
Zustand はデフォルトで効率的ですが、さらなる最適化が可能です:
typescript// 不要な再レンダリングを防ぐ
const UserName = () => [object Object]
  // 特定のプロパティのみを購読
  const name = useUserStore((state) => state.user?.name);
  return <span>{name}</span>;
};
// メモ化による最適化
const ExpensiveComponent = memo(() => {
  const data = useDataStore((state) => state.expensiveData);
  return <div>{/* 重い処理 */}</div>;
});
よくある問題と解決策
typescript// 問題: ストアの状態が期待通りに更新されない
// 解決策: immerを使用した不変性の確保
import { immer } from 'zustand/middleware/immer;
const useStore = create(
  immer((set) => ({
    items:     addItem: (item) => set((state) => {
      state.items.push(item);
    }),
  }))
);
// 問題: 非同期処理での状態管理
// 解決策: 適切なエラーハンドリング
const useAsyncStore = create((set) => ({
  data: null,
  loading: false,
  error: null,
  fetchData: async () => {
    set({ loading: true, error: null });
    try [object Object]      const data = await api.getData();
      set({ data, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));
パフォーマンス最適化のポイント
不要な再レンダリングの防止
Zustand の最大の利点の一つは、必要な部分のみを購読できることです:
typescript// 効率的なセレクターの使用
const useOptimizedStore = () => [object Object]
  // 複数の値を一度に取得
  const { user, cart } = useStore((state) => ({
    user: state.user,
    cart: state.cart,
  }));
  // 特定の値のみを購読
  const userName = useStore((state) => state.user?.name);
  const cartCount = useStore((state) => state.cart.items.length);
  return { user, cart, userName, cartCount };
};
メモリリークの防止
typescript// コンポーネントのアンマウント時にクリーンアップ
const useCleanupStore = create((set) => ([object Object]subscriptions: new Set(),
  subscribe: (callback) => {
    const subscription = { id: Date.now(), callback };
    set((state) => ({
      subscriptions: new Set([...state.subscriptions, subscription])
    }));
    // クリーンアップ関数を返す
    return () => [object Object]
      set((state) => {
        const newSubscriptions = new Set(state.subscriptions);
        newSubscriptions.delete(subscription);
        return { subscriptions: newSubscriptions };
      });
    };
  },
));
バンドルサイズの最適化
typescript// 必要な部分のみをインポート
import { create } fromzustand';
import { persist } from 'zustand/middleware';
// 開発時のみDevToolsを使用
const devTools = process.env.NODE_ENV === development'
  ? require('zustand/middleware).devtools
  : (fn) => fn;
const useStore = create(
  devTools(
    persist(
      (set) => ({
        // ストアの定義
      }),
   [object Object]name: 'app-storage' }
    )
  )
);
まとめ
Zustand への移行は、最初は複雑に感じるかもしれません。しかし、段階的なアプローチを取ることで、リスクを最小限に抑えながら、より効率的で保守性の高い状態管理システムを構築できます。
移行の成功の鍵は、以下の点にあります:
1 十分な計画と分析 - 現在の状態管理を深く理解する 2. 段階的な実装 - 一度にすべてを変更しようとしない 3. 徹底的なテスト - 各段階で動作を確認する 4. チームの教育 - 新しいパターンに慣れる時間を設ける
Zustand のシンプルさとパフォーマンスの高さは、開発体験を大幅に向上させます。レガシーな状態管理からの移行は、短期的には労力を要しますが、長期的には確実に価値のある投資となるでしょう。
移行を成功させるためには、焦らずに一歩ずつ進めることが重要です。各ステップで学んだことを活かし、チーム全体で新しい状態管理パターンに慣れていくことで、より良いアプリケーションを構築できるようになります。
関連リンク
- Zustand 公式ドキュメント -Zustand DevTools](https://github.com/pmndrs/zustand/tree/main/src/middleware/devtools)
 - React 状態管理の比較
 - TypeScript と Zustand -Zustand ベストプラクティス](https://github.com/pmndrs/zustand#best-practices)
 
articleZustand × useTransition 概説:並列レンダリング時代に安全な更新を設計する
articleフィーチャーフラグ運用:Zustand で段階的リリースとリモート設定を実装
articleオフラインファースト設計:Zustand で楽観的 UI とロールバックを実現
articleZustand Selector パターン早見表:equalityFn/shallow/構造的共有の勘所
articleTurborepo で Zustand スライスをパッケージ化:Monorepo 運用の初期設定
articleZustand × TanStack Query × SWR:キャッシュ・再検証・型安全の実運用比較
articleWebSocket が「200 OK で Upgrade されない」原因と対処:プロキシ・ヘッダー・TLS の落とし穴
articleWebRTC 本番運用の SLO 設計:接続成功率・初画出し時間・通話継続率の基準値
articleAstro のレンダリング戦略を一望:MPA× 部分ハイドレーションの強みを図解解説
articleWebLLM が読み込めない時の原因と解決策:CORS・MIME・パス問題を総点検
articleVitest ESM/CJS 混在で `Cannot use import statement outside a module` が出る技術対処集
articleテスト環境比較:Vitest vs Jest vs Playwright CT ― Vite プロジェクトの最適解
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来