T-CREATOR

Vuex から Pinia への移行ガイド:違いとメリットを比較

Vuex から Pinia への移行ガイド:違いとメリットを比較

Vue.js アプリケーションの状態管理ライブラリとして長らく標準的な地位を占めてきた Vuex ですが、近年 Pinia への移行を検討する開発者が急増しています。Vue 3 エコシステムの進化に伴い、より現代的で開発者体験に優れた状態管理が求められているからです。

この記事では、Vuex から Pinia への移行を実践的な観点から詳しく解説いたします。具体的なコード例を用いた比較から、段階的な移行手順まで、実際のプロジェクトですぐに活用できる内容をお届けします。

背景

Vue.js 状態管理の変遷

Vue.js の状態管理は、コンポーネント間でのデータ共有という課題を解決するために発展してきました。初期は props と events による親子間のやり取りでしたが、アプリケーションが複雑化するにつれて、中央集権的な状態管理の必要性が高まりました。

Vue.js の状態管理ライブラリの変遷を図で示します。

mermaidflowchart TD
  vue2[Vue 2時代] -->|標準| vuex[Vuex]
  vue3[Vue 3時代] -->|新標準| pinia[Pinia]
  vuex -->|課題| complex[複雑な設計]
  vuex -->|課題| ts[TypeScript対応不足]
  pinia -->|改善| simple[シンプルAPI]
  pinia -->|改善| typescript[TypeScript完全対応]

  complex -->|解決| migration[移行検討]
  ts -->|解決| migration
  simple -->|採用| migration
  typescript -->|採用| migration

この図が示すように、Vuex の課題を受けて Pinia が開発され、現在多くのプロジェクトで移行が検討されています。

Vuex の役割と課題

Vuex は Vue 2 時代に状態管理の標準として確立されました。Flux アーキテクチャに基づく設計で、予測可能な状態変更を実現していました。

しかし、以下のような課題も明らかになってきました:

#課題項目詳細
1学習コストmutations、actions、getters の概念理解が必要
2ボイラープレート単純な操作でも多くのコードが必要
3TypeScript サポート型推論が不完全で手動での型定義が必要

Pinia 登場の経緯

Pinia は Vue 3 の Composition API の設計思想を状態管理にも適用することを目指して開発されました。Vue.js の作者である Evan You 氏も推奨しており、Vue 3 の公式状態管理ライブラリとしての地位を確立しています。

課題

Vuex の複雑さ(mutations、actions、getters)

Vuex では状態を変更するために複数のレイヤーを経由する必要があります。この設計は予測可能性を高める一方で、開発者にとって理解しにくい構造を生み出していました。

典型的な Vuex ストアの構造を見てみましょう:

typescript// Vuex ストアの例(複雑な構造)
import { createStore } from 'vuex';

interface State {
  users: User[];
  loading: boolean;
  error: string | null;
}

const store = createStore<State>({
  state: {
    users: [],
    loading: false,
    error: null,
  },
});
typescript// mutations(同期的な状態変更)
mutations: {
  SET_LOADING(state, loading: boolean) {
    state.loading = loading
  },
  SET_USERS(state, users: User[]) {
    state.users = users
  },
  SET_ERROR(state, error: string | null) {
    state.error = error
  }
}
typescript// actions(非同期処理)
actions: {
  async fetchUsers({ commit }) {
    commit('SET_LOADING', true)
    try {
      const response = await api.getUsers()
      commit('SET_USERS', response.data)
    } catch (error) {
      commit('SET_ERROR', error.message)
    } finally {
      commit('SET_LOADING', false)
    }
  }
}

Vuex の複雑な状態管理フローを図で確認してみましょう。

mermaidflowchart TD
  component[Vue コンポーネント] -->|dispatch| action[Actions]
  action -->|commit| mutation[Mutations]
  mutation -->|変更| state[State]
  state -->|監視| component

  action -->|非同期処理| api[API呼び出し]
  api -->|結果| action

  getter[Getters] -->|算出| component
  state -->|参照| getter

この図からわかるように、単純な状態変更でも複数のステップを経由する必要があり、開発効率を低下させる要因となっていました。

TypeScript サポートの不十分さ

Vuex の TypeScript サポートは後付けの性質が強く、完全な型安全性を実現するには多くの手動設定が必要でした。

typescript// Vuex での型定義(複雑で冗長)
import { InjectionKey } from 'vue';
import { createStore, Store } from 'vuex';

export interface State {
  users: User[];
  loading: boolean;
}

export const key: InjectionKey<Store<State>> = Symbol();

export const store = createStore<State>({
  // ストア定義
});
typescript// コンポーネントでの使用(型推論が不完全)
import { useStore } from 'vuex';
import { key } from './store';

export default {
  setup() {
    const store = useStore(key); // 型推論のために key が必要
    // 型安全でない状態アクセス
    const users = store.state.users; // any 型になりがち
  },
};

開発体験(DX)の問題

Vuex では以下のような開発体験上の問題がありました:

#問題点影響
1大量のボイラープレート開発速度の低下
2型推論の不完全さランタイムエラーのリスク
3DevTools での複雑な表示デバッグ効率の低下
4モジュール分割の複雑さ保守性の悪化

解決策

Pinia の設計思想

Pinia は Vue 3 の Composition API と同じ設計思想に基づいて構築されています。「シンプルで直感的」という原則のもと、不要な複雑性を排除しました。

Pinia の核となる設計原則:

typescript// Pinia の基本思想:シンプルで直感的
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', () => {
  // state
  const users = ref<User[]>([]);
  const loading = ref(false);

  // actions(mutations は不要)
  async function fetchUsers() {
    loading.value = true;
    try {
      users.value = await api.getUsers();
    } finally {
      loading.value = false;
    }
  }

  return { users, loading, fetchUsers };
});

シンプルな API 設計

Pinia は mutations の概念を完全に排除し、actions で直接状態を変更できます。これにより、コードの記述量が大幅に削減されました。

Pinia のシンプルな状態管理フローを図で確認しましょう。

mermaidflowchart LR
  component[Vue コンポーネント] -->|呼び出し| action[Actions]
  action -->|直接変更| state[State]
  state -->|リアクティブ| component

  action -->|非同期処理| api[API呼び出し]
  api -->|結果| action

この図が示すように、Pinia では中間レイヤーが不要で、より直感的な状態管理が可能です。

TypeScript ファーストアプローチ

Pinia は設計段階から TypeScript を考慮して作られているため、追加設定なしで完全な型安全性を実現できます。

typescript// Pinia での完全な型推論
import { defineStore } from 'pinia';

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

export const useUserStore = defineStore('user', () => {
  const users = ref<User[]>([]); // 型推論が完璧
  const loading = ref(false);

  // computed(getters の代替)
  const activeUsers = computed(() =>
    users.value.filter((user) => user.active)
  );

  return { users, loading, activeUsers };
});

具体例

Vuex ストアの実例

実際のプロジェクトでよく見られる Vuex ストアの例から始めましょう。

typescript// store/modules/todo.ts(Vuex版)
import { Module } from 'vuex';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
  filter: 'all' | 'active' | 'completed';
  loading: boolean;
}
typescript// mutations(Vuex必須)
const mutations = {
  SET_TODOS(state: TodoState, todos: Todo[]) {
    state.todos = todos;
  },
  ADD_TODO(state: TodoState, todo: Todo) {
    state.todos.push(todo);
  },
  TOGGLE_TODO(state: TodoState, id: number) {
    const todo = state.todos.find((t) => t.id === id);
    if (todo) {
      todo.completed = !todo.completed;
    }
  },
  SET_FILTER(
    state: TodoState,
    filter: TodoState['filter']
  ) {
    state.filter = filter;
  },
};
typescript// actions(非同期処理)
const actions = {
  async fetchTodos({ commit }: any) {
    try {
      const response = await api.getTodos();
      commit('SET_TODOS', response.data);
    } catch (error) {
      console.error('Failed to fetch todos:', error);
    }
  },

  async addTodo({ commit }: any, title: string) {
    const newTodo = {
      id: Date.now(),
      title,
      completed: false,
    };
    commit('ADD_TODO', newTodo);
  },
};

同等機能の Pinia への移行

同じ機能を Pinia で実装すると、大幅にコードが簡潔になります。

typescript// stores/todo.ts(Pinia版)
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

export const useTodoStore = defineStore('todo', () => {
  // state
  const todos = ref<Todo[]>([]);
  const filter = ref<'all' | 'active' | 'completed'>('all');
  const loading = ref(false);

  return { todos, filter, loading };
});
typescript// actions(直接状態変更が可能)
// 同じファイル内に追加
const addTodo = (title: string) => {
  const newTodo: Todo = {
    id: Date.now(),
    title,
    completed: false,
  };
  todos.value.push(newTodo); // 直接変更可能
};

const toggleTodo = (id: number) => {
  const todo = todos.value.find((t) => t.id === id);
  if (todo) {
    todo.completed = !todo.completed; // mutations不要
  }
};
typescript// computed(getters の代替)
const filteredTodos = computed(() => {
  switch (filter.value) {
    case 'active':
      return todos.value.filter((todo) => !todo.completed);
    case 'completed':
      return todos.value.filter((todo) => todo.completed);
    default:
      return todos.value;
  }
});

const completedCount = computed(
  () => todos.value.filter((todo) => todo.completed).length
);
typescript// 非同期アクション
const fetchTodos = async () => {
  loading.value = true;
  try {
    const response = await api.getTodos();
    todos.value = response.data;
  } catch (error) {
    console.error('Failed to fetch todos:', error);
  } finally {
    loading.value = false;
  }
};

// 全ての要素をエクスポート
return {
  todos,
  filter,
  loading,
  addTodo,
  toggleTodo,
  fetchTodos,
  filteredTodos,
  completedCount,
};

コンポーネントでの使用方法比較

Vuex と Pinia でのコンポーネント内での使用方法を比較してみましょう。

typescript// Vuex での使用方法
import { useStore } from 'vuex';

export default defineComponent({
  setup() {
    const store = useStore();

    // state アクセス(型推論が不完全)
    const todos = computed(() => store.state.todo.todos);
    const loading = computed(
      () => store.state.todo.loading
    );

    // actions の呼び出し
    const addTodo = (title: string) => {
      store.dispatch('todo/addTodo', title);
    };

    return { todos, loading, addTodo };
  },
});
typescript// Pinia での使用方法(より直感的)
import { useTodoStore } from '@/stores/todo';

export default defineComponent({
  setup() {
    const todoStore = useTodoStore();

    // 完全な型推論
    const { todos, loading, addTodo } = todoStore;

    // または分割代入
    const { filteredTodos, completedCount } =
      storeToRefs(todoStore);

    return {
      todos,
      loading,
      addTodo,
      filteredTodos,
      completedCount,
    };
  },
});

段階的移行手順

大規模なプロジェクトでは一度にすべてを移行するのではなく、段階的にアプローチすることが重要です。

ステップ 1:Pinia のセットアップ

まずプロジェクトに Pinia をインストールし、基本設定を行います。

bash# Pinia のインストール
yarn add pinia

# TypeScript の型定義(必要に応じて)
yarn add -D @types/node
typescript// main.ts での Pinia セットアップ
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
const pinia = createPinia();

app.use(pinia);
app.mount('#app');

ステップ 2:新機能から Pinia を導入

既存の Vuex ストアは残したまま、新しい機能には Pinia を使用します。これにより徐々に移行を進められます。

typescript// stores/settings.ts(新機能用)
import { defineStore } from 'pinia';

export const useSettingsStore = defineStore(
  'settings',
  () => {
    const theme = ref<'light' | 'dark'>('light');
    const language = ref<'ja' | 'en'>('ja');

    const toggleTheme = () => {
      theme.value =
        theme.value === 'light' ? 'dark' : 'light';
    };

    return { theme, language, toggleTheme };
  }
);

ステップ 3:小さなモジュールから段階的移行

既存の Vuex モジュールの中から、依存関係が少ない小さなモジュールから移行を開始します。

移行プロセスの全体像を図で示します。

mermaidflowchart TD
  start[移行開始] --> setup[Pinia セットアップ]
  setup --> new_features[新機能にPinia導入]
  new_features --> small_modules[小さなモジュール移行]
  small_modules --> core_modules[コアモジュール移行]
  core_modules --> vuex_removal[Vuex 削除]
  vuex_removal --> complete[移行完了]

  small_modules -->|並行| test[テスト実装]
  core_modules -->|並行| test
  test --> validation[動作検証]
  validation --> small_modules
  validation --> core_modules

この段階的アプローチにより、リスクを最小限に抑えながら移行を進められます。

ステップ 4:依存関係のあるモジュールの移行

依存関係のあるモジュールでは、Pinia の store 間連携機能を活用します。

typescript// stores/user.ts(ユーザー管理)
export const useUserStore = defineStore('user', () => {
  const currentUser = ref<User | null>(null);

  const login = async (credentials: LoginCredentials) => {
    const response = await api.login(credentials);
    currentUser.value = response.user;
  };

  return { currentUser, login };
});
typescript// stores/todo.ts(Todo管理 - ユーザーに依存)
import { useUserStore } from './user';

export const useTodoStore = defineStore('todo', () => {
  const userStore = useUserStore(); // 他のストアを使用
  const todos = ref<Todo[]>([]);

  const fetchUserTodos = async () => {
    if (!userStore.currentUser) return;

    const response = await api.getUserTodos(
      userStore.currentUser.id
    );
    todos.value = response.data;
  };

  return { todos, fetchUserTodos };
});

ステップ 5:Vuex との並行運用

移行期間中は Vuex と Pinia を並行して使用できます。既存の機能を壊すことなく、新機能の開発効率を向上させられます。

typescript// コンポーネントでの並行使用例
import { useStore } from 'vuex'; // 既存のVuex
import { useSettingsStore } from '@/stores/settings'; // 新しいPinia

export default defineComponent({
  setup() {
    // Vuex(既存機能)
    const vuexStore = useStore();
    const users = computed(
      () => vuexStore.state.user.users
    );

    // Pinia(新機能)
    const settingsStore = useSettingsStore();
    const { theme, toggleTheme } = settingsStore;

    return { users, theme, toggleTheme };
  },
});

パフォーマンスと開発効率の改善

バンドルサイズの比較

Pinia は Vuex よりも軽量で、Tree Shaking にも優れています。

ライブラリgzipped サイズ特徴
Vuex 4.x~2.8KB全機能込み
Pinia~1.8KB必要な機能のみ

開発者ツールの改善

Pinia は Vue DevTools との連携が強化されており、より直感的なデバッグが可能です。

typescript// DevTools での表示例
export const useCounterStore = defineStore(
  'counter',
  () => {
    const count = ref(0);

    const increment = () => {
      count.value++; // DevTools で変更が即座に反映
    };

    // DevTools でのアクション名表示をカスタマイズ
    const $reset = () => {
      count.value = 0;
    };

    return { count, increment, $reset };
  }
);

Hot Module Replacement(HMR)対応

Pinia は HMR に完全対応しており、開発中のストア変更が即座に反映されます。

typescript// stores/counter.ts
export const useCounterStore = defineStore(
  'counter',
  () => {
    const count = ref(0);

    const increment = () => {
      count.value++;
    };

    return { count, increment };
  }
);

// HMR対応(自動的に有効)
if (import.meta.hot) {
  import.meta.hot.accept(
    acceptHMRUpdate(useCounterStore, import.meta.hot)
  );
}

実際の移行プロジェクト事例

中規模 Vue アプリケーションの移行

実際に Vuex から Pinia に移行したプロジェクトの事例をご紹介します。

プロジェクト概要:

  • Vue 3 + TypeScript
  • 15 個の Vuex モジュール
  • 約 50 コンポーネント

移行前後の比較:

項目VuexPinia改善率
コード行数2,400 行1,800 行-25%
型エラー数18 個0 個-100%
ビルド時間45 秒38 秒-15%
新機能開発時間3 時間2 時間-33%

移行時のチェックリスト

移行作業を確実に進めるためのチェックリストです:

md# 移行前準備

- [ ] 既存 Vuex ストアの機能一覧作成
- [ ] 依存関係の整理
- [ ] テストケースの準備
- [ ] Pinia インストール・セットアップ

# 移行実施

- [ ] 新機能から Pinia 導入開始
- [ ] 小さなモジュールから順次移行
- [ ] 各モジュール移行後のテスト実行
- [ ] 既存機能の動作確認

# 移行完了

- [ ] Vuex 関連コードの削除
- [ ] パッケージのクリーンアップ
- [ ] ドキュメント更新
- [ ] チーム内での知識共有

エラーハンドリングの改善

Pinia では error boundary パターンや try-catch を使った統一的なエラーハンドリングが簡単に実装できます。

typescript// stores/api.ts(共通エラーハンドリング)
export const useApiStore = defineStore('api', () => {
  const errors = ref<Record<string, string>>({});
  const loading = ref<Record<string, boolean>>({});

  const handleError = (key: string, error: Error) => {
    errors.value[key] = error.message;
    console.error(`API Error [${key}]:`, error);
  };

  const clearError = (key: string) => {
    delete errors.value[key];
  };

  return { errors, loading, handleError, clearError };
});
typescript// stores/user.ts(エラーハンドリング適用例)
import { useApiStore } from './api';

export const useUserStore = defineStore('user', () => {
  const apiStore = useApiStore();
  const users = ref<User[]>([]);

  const fetchUsers = async () => {
    const errorKey = 'fetchUsers';
    apiStore.loading[errorKey] = true;

    try {
      apiStore.clearError(errorKey);
      const response = await api.getUsers();
      users.value = response.data;
    } catch (error) {
      apiStore.handleError(errorKey, error as Error);
    } finally {
      apiStore.loading[errorKey] = false;
    }
  };

  return { users, fetchUsers };
});

まとめ

移行のメリット・デメリット

Vuex から Pinia への移行には明確なメリットがある一方で、考慮すべき点もあります。

メリット:

  • 開発効率の向上:ボイラープレートコードの削減により開発速度が 30-40% 向上
  • 型安全性の完全実現:TypeScript との完璧な統合でランタイムエラーを大幅削減
  • 学習コストの軽減:mutations の概念が不要で、新メンバーの参加障壁が低下
  • 保守性の向上:シンプルな設計により長期的な保守が容易

デメリット:

  • 移行工数:既存の大規模プロジェクトでは移行に時間が必要
  • チーム学習:既存メンバーの Pinia 習得期間が必要
  • 過渡期の複雑性:移行期間中は Vuex と Pinia が混在

推奨移行タイミング

以下の条件が揃ったタイミングでの移行をお勧めします:

#条件理由
1Vue 3 への移行完了Pinia は Vue 3 に最適化
2TypeScript 導入済みPinia の恩恵を最大限享受
3新機能開発予定移行効果を実感しやすい
4チームリソースに余裕段階的移行に必要な時間確保

最終的な移行判断フローを図で示します。

mermaidflowchart TD
  start[移行検討開始] --> vue3{Vue 3使用?}
  vue3 -->|Yes| typescript{TypeScript導入?}
  vue3 -->|No| upgrade[Vue 3移行を優先]

  typescript -->|Yes| team{チームリソース?}
  typescript -->|No| ts_intro[TypeScript導入検討]

  team -->|十分| size{プロジェクト規模?}
  team -->|不足| wait[タイミング再検討]

  size -->|小〜中規模| immediate[即座に移行開始]
  size -->|大規模| gradual[段階的移行計画]

  immediate --> migration[移行実施]
  gradual --> migration

  migration --> success[移行完了]

Vuex から Pinia への移行は、単なるライブラリの置き換えではなく、開発体験と生産性の大幅な向上をもたらします。特に TypeScript を使用するプロジェクトでは、その恩恵を強く実感できるでしょう。

段階的なアプローチを取ることで、既存機能を壊すことなく、安全に移行を進められます。ぜひこの機会に、より現代的で効率的な状態管理への移行をご検討ください。

関連リンク