T-CREATOR

Zustand のレガシーな状態管理からの移行ステップ

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);
});

移行時の注意点とベストプラクティス

段階的移行の重要性

一度にすべてを移行しようとすると、予期しない問題が発生する可能性があります。以下の順序で進めることをお勧めします:

  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 のシンプルさとパフォーマンスの高さは、開発体験を大幅に向上させます。レガシーな状態管理からの移行は、短期的には労力を要しますが、長期的には確実に価値のある投資となるでしょう。

移行を成功させるためには、焦らずに一歩ずつ進めることが重要です。各ステップで学んだことを活かし、チーム全体で新しい状態管理パターンに慣れていくことで、より良いアプリケーションを構築できるようになります。

関連リンク