T-CREATOR

Vite でグローバル状態管理を実現する方法

Vite でグローバル状態管理を実現する方法

Vite は高速なビルドツールとして人気を集めていますが、実際のアプリケーション開発では状態管理が重要な課題となります。コンポーネント間でのデータ共有や、アプリケーション全体の状態を効率的に管理する方法を知ることで、より保守性の高いアプリケーションを構築できるようになります。

この記事では、Vite プロジェクトでグローバル状態管理を実現する様々な方法を、実際のコード例とエラーケースを含めて詳しく解説していきます。初心者の方でも理解しやすいように、段階的に説明していきましょう。

グローバル状態管理の必要性

なぜグローバル状態管理が必要なのか

モダンな Web アプリケーションでは、複数のコンポーネントが同じデータを共有する必要があります。例えば、ユーザー情報、認証状態、ショッピングカートの内容などです。

javascript// 問題のある例:props drilling
function App() {
  const [user, setUser] = useState(null);

  return (
    <div>
      <Header user={user} />
      <MainContent user={user} />
      <Sidebar user={user} />
      <Footer user={user} />
    </div>
  );
}

この方法では、データを必要としない中間コンポーネントにも props を渡す必要があり、コードが複雑になってしまいます。

グローバル状態管理のメリット

  • データの一元管理: アプリケーション全体の状態を一箇所で管理
  • コンポーネント間の疎結合: props drilling を避けられる
  • デバッグの容易さ: 状態の変化を追跡しやすい
  • パフォーマンスの最適化: 必要な部分のみ再レンダリング

Vite プロジェクトでの状態管理の選択肢

Vite はフレームワークに依存しないため、様々な状態管理ライブラリを使用できます。主な選択肢を比較してみましょう。

ライブラリ学習コストバンドルサイズ型安全性適用場面
Context API小〜中規模
Zustand小〜大規模
Redux Toolkit大規模
PiniaVue.js

Context API を使った状態管理

Context API の基本概念

React の Context API は、追加のライブラリなしでグローバル状態管理を実現できる組み込み機能です。

まず、Vite プロジェクトで Context API を使用する基本的なセットアップを見てみましょう。

typescript// src/contexts/UserContext.tsx
import React, {
  createContext,
  useContext,
  useState,
  ReactNode,
} from 'react';

// ユーザー情報の型定義
interface User {
  id: string;
  name: string;
  email: string;
}

// Contextの型定義
interface UserContextType {
  user: User | null;
  login: (userData: User) => void;
  logout: () => void;
}

// Contextの作成
const UserContext = createContext<
  UserContextType | undefined
>(undefined);

// Providerコンポーネント
export function UserProvider({
  children,
}: {
  children: ReactNode;
}) {
  const [user, setUser] = useState<User | null>(null);

  const login = (userData: User) => {
    setUser(userData);
  };

  const logout = () => {
    setUser(null);
  };

  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  );
}

// カスタムフック
export function useUser() {
  const context = useContext(UserContext);
  if (context === undefined) {
    throw new Error(
      'useUser must be used within a UserProvider'
    );
  }
  return context;
}

Context API の実装例

次に、実際に Context API を使用するコンポーネントの例を見てみましょう。

typescript// src/components/LoginForm.tsx
import React, { useState } from 'react';
import { useUser } from '../contexts/UserContext';

export function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const { login } = useUser();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // 実際のアプリケーションではAPI呼び出しを行う
    const userData = {
      id: '1',
      name: 'テストユーザー',
      email: email,
    };

    login(userData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type='email'
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder='メールアドレス'
        required
      />
      <input
        type='password'
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder='パスワード'
        required
      />
      <button type='submit'>ログイン</button>
    </form>
  );
}

Context API のよくあるエラーと解決策

Context API を使用する際によく遭遇するエラーを紹介します。

typescript// エラー例1: ContextがProviderで囲まれていない
function MyComponent() {
  const { user } = useUser(); // Error: useUser must be used within a UserProvider
  return <div>{user?.name}</div>;
}

// 解決策: App.tsxでProviderで囲む
function App() {
  return (
    <UserProvider>
      <MyComponent />
    </UserProvider>
  );
}
typescript// エラー例2: 型定義の不備
const UserContext = createContext(null); // 型安全性が失われる

// 解決策: 適切な型定義
const UserContext = createContext<
  UserContextType | undefined
>(undefined);

Zustand を使った状態管理

Zustand の特徴と利点

Zustand は軽量で直感的な状態管理ライブラリです。Redux の複雑さを避けながら、強力な機能を提供します。

まず、Zustand を Vite プロジェクトにインストールしましょう。

bashyarn add zustand

Zustand ストアの作成

Zustand での基本的なストア作成方法を見てみましょう。

typescript// src/stores/userStore.ts
import { create } from 'zustand';

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

interface UserStore {
  user: User | null;
  isLoading: boolean;
  error: string | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  clearError: () => void;
}

export const useUserStore = create<UserStore>(
  (set, get) => ({
    user: null,
    isLoading: false,
    error: null,

    login: async (email: string, password: string) => {
      set({ isLoading: true, error: null });

      try {
        // 実際のAPI呼び出しをシミュレート
        await new Promise((resolve) =>
          setTimeout(resolve, 1000)
        );

        const userData = {
          id: '1',
          name: 'テストユーザー',
          email: email,
        };

        set({ user: userData, isLoading: false });
      } catch (error) {
        set({
          error: 'ログインに失敗しました',
          isLoading: false,
        });
      }
    },

    logout: () => {
      set({ user: null, error: null });
    },

    clearError: () => {
      set({ error: null });
    },
  })
);

Zustand を使用したコンポーネント

Zustand ストアを使用するコンポーネントの実装例です。

typescript// src/components/UserProfile.tsx
import React from 'react';
import { useUserStore } from '../stores/userStore';

export function UserProfile() {
  const { user, logout } = useUserStore();

  if (!user) {
    return <div>ログインしてください</div>;
  }

  return (
    <div>
      <h2>ユーザープロフィール</h2>
      <p>名前: {user.name}</p>
      <p>メール: {user.email}</p>
      <button onClick={logout}>ログアウト</button>
    </div>
  );
}

Zustand の高度な機能

Zustand には、状態の永続化やデバッグ機能など、便利な機能があります。

typescript// src/stores/persistentStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface Settings {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
}

interface SettingsStore {
  settings: Settings;
  updateTheme: (theme: Settings['theme']) => void;
  updateLanguage: (language: Settings['language']) => void;
}

export const useSettingsStore = create<SettingsStore>()(
  persist(
    (set) => ({
      settings: {
        theme: 'light',
        language: 'ja',
      },

      updateTheme: (theme) =>
        set((state) => ({
          settings: { ...state.settings, theme },
        })),

      updateLanguage: (language) =>
        set((state) => ({
          settings: { ...state.settings, language },
        })),
    }),
    {
      name: 'settings-storage', // localStorageのキー名
    }
  )
);

Redux Toolkit を使った状態管理

Redux Toolkit の導入

大規模なアプリケーションでは、Redux Toolkit が強力な選択肢となります。

bashyarn add @reduxjs/toolkit react-redux

Redux Toolkit ストアの設定

Redux Toolkit での基本的なストア設定を見てみましょう。

typescript// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import cartReducer from './cartSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Redux Toolkit スライスの作成

Redux Toolkit のスライス機能を使用した状態管理の実装例です。

typescript// src/store/userSlice.ts
import {
  createSlice,
  createAsyncThunk,
  PayloadAction,
} from '@reduxjs/toolkit';

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

interface UserState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

const initialState: UserState = {
  user: null,
  isLoading: false,
  error: null,
};

// 非同期アクション
export const loginUser = createAsyncThunk(
  'user/login',
  async (credentials: {
    email: string;
    password: string;
  }) => {
    // 実際のAPI呼び出しをシミュレート
    await new Promise((resolve) =>
      setTimeout(resolve, 1000)
    );

    return {
      id: '1',
      name: 'テストユーザー',
      email: credentials.email,
    };
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    logout: (state) => {
      state.user = null;
      state.error = null;
    },
    clearError: (state) => {
      state.error = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(loginUser.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(loginUser.fulfilled, (state, action) => {
        state.isLoading = false;
        state.user = action.payload;
      })
      .addCase(loginUser.rejected, (state, action) => {
        state.isLoading = false;
        state.error =
          action.error.message || 'ログインに失敗しました';
      });
  },
});

export const { logout, clearError } = userSlice.actions;
export default userSlice.reducer;

Redux Toolkit の使用例

Redux Toolkit を使用するコンポーネントの実装例です。

typescript// src/components/ReduxLoginForm.tsx
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { loginUser } from '../store/userSlice';
import { RootState, AppDispatch } from '../store';

export function ReduxLoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const dispatch = useDispatch<AppDispatch>();
  const { isLoading, error } = useSelector(
    (state: RootState) => state.user
  );

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    dispatch(loginUser({ email, password }));
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <div style={{ color: 'red' }}>{error}</div>}
      <input
        type='email'
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder='メールアドレス'
        required
      />
      <input
        type='password'
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder='パスワード'
        required
      />
      <button type='submit' disabled={isLoading}>
        {isLoading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  );
}

Pinia を使った状態管理(Vue.js)

Pinia の特徴

Vue.js を使用している場合、Pinia が推奨される状態管理ライブラリです。

bashyarn add pinia

Pinia ストアの作成

Pinia での基本的なストア作成方法を見てみましょう。

typescript// src/stores/user.ts
import { defineStore } from 'pinia';

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

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    isLoading: false,
    error: null as string | null,
  }),

  getters: {
    isLoggedIn: (state) => !!state.user,
    userName: (state) => state.user?.name || 'ゲスト',
  },

  actions: {
    async login(email: string, password: string) {
      this.isLoading = true;
      this.error = null;

      try {
        // 実際のAPI呼び出しをシミュレート
        await new Promise((resolve) =>
          setTimeout(resolve, 1000)
        );

        this.user = {
          id: '1',
          name: 'テストユーザー',
          email: email,
        };
      } catch (error) {
        this.error = 'ログインに失敗しました';
      } finally {
        this.isLoading = false;
      }
    },

    logout() {
      this.user = null;
      this.error = null;
    },

    clearError() {
      this.error = null;
    },
  },
});

Pinia の使用例

Pinia ストアを使用する Vue コンポーネントの実装例です。

vue<!-- src/components/UserProfile.vue -->
<template>
  <div>
    <div v-if="userStore.isLoading">読み込み中...</div>
    <div v-else-if="userStore.error" class="error">
      {{ userStore.error }}
    </div>
    <div v-else-if="userStore.isLoggedIn">
      <h2>ユーザープロフィール</h2>
      <p>名前: {{ userStore.userName }}</p>
      <p>メール: {{ userStore.user?.email }}</p>
      <button @click="userStore.logout">ログアウト</button>
    </div>
    <div v-else>
      <p>ログインしてください</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '../stores/user';

const userStore = useUserStore();
</script>

<style scoped>
.error {
  color: red;
  margin-bottom: 1rem;
}
</style>

パフォーマンスの比較

各ライブラリのパフォーマンス特性

実際のプロジェクトで使用する際のパフォーマンスを比較してみましょう。

ライブラリ初期バンドルサイズ実行時パフォーマンスメモリ使用量学習コスト
Context API0KB
Zustand~2KB
Redux Toolkit~15KB
Pinia~5KB

パフォーマンス最適化のベストプラクティス

typescript// 最適化例:Zustandでの選択的サブスクライブ
import { useUserStore } from '../stores/userStore';

// 悪い例:全体のストアをサブスクライブ
function BadComponent() {
  const store = useUserStore(); // 全体が再レンダリングされる
  return <div>{store.user?.name}</div>;
}

// 良い例:必要な部分のみサブスクライブ
function GoodComponent() {
  const userName = useUserStore(
    (state) => state.user?.name
  );
  return <div>{userName}</div>;
}

実装例とベストプラクティス

プロジェクト構造の推奨パターン

効率的な状態管理のためのプロジェクト構造を提案します。

bashsrc/
├── stores/           # 状態管理ストア
│   ├── index.ts     # ストアのエクスポート
│   ├── userStore.ts
│   └── cartStore.ts
├── hooks/           # カスタムフック
│   ├── useAuth.ts
│   └── useCart.ts
├── types/           # 型定義
│   └── store.ts
└── utils/           # ユーティリティ
    └── storeHelpers.ts

エラーハンドリングの実装

実用的なエラーハンドリングの実装例です。

typescript// src/stores/errorStore.ts
import { create } from 'zustand';

interface ErrorState {
  errors: Map<string, string>;
  addError: (key: string, message: string) => void;
  removeError: (key: string) => void;
  clearErrors: () => void;
}

export const useErrorStore = create<ErrorState>((set) => ({
  errors: new Map(),

  addError: (key, message) =>
    set((state) => {
      const newErrors = new Map(state.errors);
      newErrors.set(key, message);
      return { errors: newErrors };
    }),

  removeError: (key) =>
    set((state) => {
      const newErrors = new Map(state.errors);
      newErrors.delete(key);
      return { errors: newErrors };
    }),

  clearErrors: () => set({ errors: new Map() }),
}));

状態の永続化

重要な状態をローカルストレージに保存する実装例です。

typescript// src/utils/persistence.ts
import { useUserStore } from '../stores/userStore';

// 状態の永続化
export const persistUserState = () => {
  const user = useUserStore.getState().user;
  if (user) {
    localStorage.setItem('user', JSON.stringify(user));
  }
};

// 状態の復元
export const restoreUserState = () => {
  const savedUser = localStorage.getItem('user');
  if (savedUser) {
    try {
      const user = JSON.parse(savedUser);
      useUserStore.getState().login(user.email, '');
    } catch (error) {
      console.error('Failed to restore user state:', error);
    }
  }
};

よくあるエラーとトラブルシューティング

実際の開発で遭遇しやすいエラーとその解決策を紹介します。

typescript// エラー例1: 無限ループ
function BadComponent() {
  const [count, setCount] = useState(0);

  // 悪い例:レンダリング時に毎回新しいオブジェクトを作成
  const userStore = useUserStore();

  useEffect(() => {
    setCount(count + 1); // 無限ループが発生
  }, [userStore]); // userStoreが毎回新しいオブジェクトになる

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

// 解決策:必要な値のみをサブスクライブ
function GoodComponent() {
  const [count, setCount] = useState(0);

  const userName = useUserStore(
    (state) => state.user?.name
  );

  useEffect(() => {
    setCount(count + 1);
  }, [userName]); // 特定の値の変更のみを監視

  return <div>{count}</div>;
}
typescript// エラー例2: 型安全性の問題
// 悪い例:型定義なし
const store = create((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

// 解決策:適切な型定義
interface UserStore {
  user: User | null;
  setUser: (user: User | null) => void;
}

const store = create<UserStore>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

まとめ

Vite プロジェクトでのグローバル状態管理について、様々なアプローチを詳しく解説してきました。

重要なポイントを振り返ると:

  • Context APIは小〜中規模のアプリケーションに適しており、追加のライブラリが不要
  • Zustandは軽量で直感的、学習コストが低く、多くのプロジェクトに適している
  • Redux Toolkitは大規模アプリケーションで強力な機能を提供するが、学習コストが高い
  • Piniaは Vue.js プロジェクトで推奨される状態管理ライブラリ

選択の基準として:

  • プロジェクトの規模
  • チームの技術レベル
  • パフォーマンス要件
  • メンテナンス性

を総合的に考慮して、最適な状態管理ライブラリを選択することが重要です。

状態管理は、アプリケーションの品質と保守性に直結する重要な要素です。今回紹介した方法を参考に、プロジェクトに最適な状態管理を実装していただければと思います。

関連リンク