T-CREATOR

Vue.js と Pinia:次世代ストアの基本と使い方

Vue.js と Pinia:次世代ストアの基本と使い方

Vue.js アプリケーションを開発していると、コンポーネント間でのデータ共有や状態管理に悩むことがありますよね。そんな課題を解決する強力なソリューションがPiniaです。

Pinia は、Vue.js のための次世代状態管理ライブラリとして登場し、従来の Vuex よりもシンプルで直感的な開発体験を提供してくれます。TypeScript との親和性も高く、モダンな Vue.js アプリケーション開発には欠かせない存在となっています。

この記事では、Pinia の基本概念から実際の使い方まで、段階的に学習していきましょう。

背景

状態管理の必要性

Vue.js でアプリケーションを構築する際、単一コンポーネント内での状態管理はrefreactiveで十分対応できます。しかし、アプリケーションが成長し、複数のコンポーネントで同じデータを共有する必要が生じると、状況は一変します。

コンポーネント間でのデータ受け渡しは、親から子への props や子から親への emit で行えますが、深くネストしたコンポーネントツリーでは「プロップドリリング」という問題が発生します。データを必要としない中間コンポーネントにも、単に受け渡しのためだけに props を定義する必要があるのです。

typescript// プロップドリリングの例(推奨されない方法)
// 親コンポーネント → 子コンポーネント → 孫コンポーネント
// 中間の子コンポーネントが不要なpropsを受け取る

Pinia の登場背景

Vue.js エコシステムにおいて、状態管理といえば長らく Vuex が標準的な選択肢でした。Vuex は確実に機能しますが、以下のような課題を抱えていました。

項目Vuex の課題
1ボイラープレートコードが多い
2TypeScript サポートが不十分
3mutations と actions の概念が複雑
4モジュール化が煩雑
5DevTools でのデバッグ体験に改善の余地

このような背景から、Vue.js の作者である Evan You 氏と Vue チームは、よりシンプルで現代的な状態管理ライブラリの開発に着手しました。それが Pinia です。

Pinia は「Vuex 5」の実験的な実装としてスタートし、最終的には正式な Vue 公式推奨の状態管理ライブラリとなりました。

課題

Vuex からの移行課題

多くの開発チームが Vuex から Pinia への移行を検討する際に直面する課題があります。

既存コードベースの移行コストが最大の懸念点です。大規模なアプリケーションでは、すべてのストア、アクション、ミューテーションを書き換える必要があり、相当な工数が見込まれます。

チーム内での学習コストも考慮すべき点です。Vuex に慣れ親しんだ開発者にとって、新しい API や概念を習得する時間が必要になります。

モダンな状態管理の要求

現代の Vue.js アプリケーション開発では、以下のような要求が高まっています。

typescript// TypeScriptでの厳密な型安全性
interface User {
  id: number;
  name: string;
  email: string;
}

// Composition APIとの自然な統合
// Tree-shakingによる最適化
// Hot Module Replacementでの開発体験向上

型安全性は特に重要な要素です。大規模開発では、コンパイル時にエラーを検出できることで、バグの早期発見と修正コストの削減につながります。

Composition API との統合も求められる要素です。Vue 3 で導入された Composition API の恩恵を状態管理でも活用したいというニーズが高まっています。

開発体験の向上も無視できません。ホットリロード、デバッグツールの使いやすさ、IDE 支援など、開発効率に直結する要素が重視されています。

解決策

Pinia の特徴と利点

Pinia は前述の課題を解決するために設計された、次世代の状態管理ライブラリです。その主な特徴と利点を詳しく見ていきましょう。

シンプルな API 設計が Pinia の最大の魅力の一つです。Vuex で必要だった mutations という概念を排除し、より直感的な状態更新が可能になりました。

typescript// Vuex(従来)
const store = new Vuex.Store({
  state: { count: 0 },
  mutations: {
    INCREMENT(state) {
      state.count++;
    },
  },
  actions: {
    increment({ commit }) {
      commit('INCREMENT');
    },
  },
});

// Pinia(新しい方式)
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++;
    },
  },
});

TypeScript 完全サポートにより、型安全性が大幅に向上しています。自動的な型推論と IntellSense サポートにより、開発時のエラーを削減できます。

Tree-shaking 対応により、使用していないストアコードを自動的に除外し、バンドルサイズの最適化が可能です。

DevTools 統合では、Vue DevTools との完全な統合により、状態の変更履歴、タイムトラベルデバッグ、ホットリロードが利用できます。

設計哲学

Pinia の設計哲学は「シンプルさと実用性の両立」です。

設計原則説明
1直感的: JavaScript の自然な書き方に近い
2軽量: 必要最小限の機能に絞り込み
3拡張可能: プラグインシステムによる機能拡張
4型安全: TypeScript ファーストの設計
5テスタブル: ユニットテストが容易

この設計哲学により、学習コストを最小限に抑えながら、強力な状態管理機能を提供することが実現されています。

具体例

Pinia ストアの作成方法

Pinia を使用するための最初のステップは、プロジェクトへのインストールです。

bash# Yarnを使用したインストール
yarn add pinia

次に、Vue.js アプリケーションに Pinia を設定します。

typescript// main.ts
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');

これで、アプリケーション全体で Pinia ストアを使用する準備が整いました。

ストアの基本的な定義方法は、defineStore関数を使用します。

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

export const useCounterStore = defineStore('counter', {
  // ストアの初期状態を定義
  state: () => ({
    count: 0,
    name: 'カウンター',
  }),

  // ゲッター(computed相当)
  getters: {
    doubleCount: (state) => state.count * 2,
  },

  // アクション(methods相当)
  actions: {
    increment() {
      this.count++;
    },
  },
});

基本的な状態管理

ストアを定義したら、コンポーネントから状態にアクセスしてみましょう。

typescript// components/Counter.vue
<template>
  <div>
    <h2>{{ store.name }}</h2>
    <p>カウント: {{ store.count }}</p>
    <p>ダブルカウント: {{ store.doubleCount }}</p>
    <button @click="store.increment">増加</button>
  </div>
</template>
typescript<script setup lang='ts'>
  import {useCounterStore} from '@/stores/counter' //
  ストアのインスタンスを取得 const store = useCounterStore()
</script>

この基本的な例では、ストアから状態を読み取り、アクションを実行しています。store.countのように直接プロパティにアクセスできるシンプルさが特徴的です。

リアクティブな分割代入を使用する場合は、storeToRefsを使用します。

typescript<script setup lang='ts'>
  import {storeToRefs} from 'pinia' import {useCounterStore}{' '}
  from '@/stores/counter' const store = useCounterStore() //
  リアクティブ性を保持した分割代入 const{' '}
  {(count, name, doubleCount)} = storeToRefs(store) //
  アクションは通常の分割代入 const {increment} = store
</script>

ゲッター(Getters)の活用

ゲッターは、ストアの状態から計算された値を取得するためのメカニズムです。Vue.js のcomputedプロパティと同様の機能を提供します。

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

interface User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  isActive: boolean;
}

export const useUserStore = defineStore('user', {
  state: () => ({
    users: [] as User[],
    currentUserId: null as number | null,
  }),

  getters: {
    // 基本的なゲッター
    activeUsers: (state) =>
      state.users.filter((user) => user.isActive),

    // 他のゲッターを参照
    activeUserCount(): number {
      return this.activeUsers.length;
    },

    // パラメータを受け取るゲッター
    getUserById: (state) => {
      return (userId: number) =>
        state.users.find((user) => user.id === userId);
    },

    // 現在のユーザー情報
    currentUser(): User | undefined {
      if (this.currentUserId === null) return undefined;
      return this.getUserById(this.currentUserId);
    },
  },
});

ゲッターの使用例をコンポーネントで確認してみましょう。

typescript<template>
  <div>
    <h3>アクティブユーザー数: {{ activeUserCount }}</h3>
    <div v-if="currentUser">
      <p>現在のユーザー: {{ currentUser.firstName }} {{ currentUser.lastName }}</p>
    </div>
    <ul>
      <li v-for="user in activeUsers" :key="user.id">
        {{ user.firstName }} ({{ user.email }})
      </li>
    </ul>
  </div>
</template>
typescript<script setup lang='ts'>
  import {storeToRefs} from 'pinia' import {useUserStore}{' '}
  from '@/stores/user' const userStore = useUserStore()
  const {(activeUsers, activeUserCount, currentUser)} =
  storeToRefs(userStore)
</script>

アクション(Actions)の実装

アクションは、ストアの状態を変更する処理を担当します。同期処理と非同期処理の両方に対応できます。

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

interface Todo {
  id: number;
  text: string;
  completed: boolean;
  createdAt: Date;
}

export const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [] as Todo[],
    loading: false,
    error: null as string | null,
  }),

  getters: {
    completedTodos: (state) =>
      state.todos.filter((todo) => todo.completed),

    pendingTodos: (state) =>
      state.todos.filter((todo) => !todo.completed),

    todoCount: (state) => state.todos.length,
  },

  actions: {
    // 同期アクション
    addTodo(text: string) {
      const newTodo: Todo = {
        id: Date.now(),
        text,
        completed: false,
        createdAt: new Date(),
      };
      this.todos.push(newTodo);
    },

    toggleTodo(id: number) {
      const todo = this.todos.find((t) => t.id === id);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },

    removeTodo(id: number) {
      const index = this.todos.findIndex(
        (t) => t.id === id
      );
      if (index > -1) {
        this.todos.splice(index, 1);
      }
    },
  },
});

非同期アクションの実装例も見てみましょう。

typescript// stores/todo.ts(非同期処理を追加)
export const useTodoStore = defineStore('todo', {
  // ... 前述の定義

  actions: {
    // ... 前述のアクション

    // APIからTodoリストを取得
    async fetchTodos() {
      this.loading = true;
      this.error = null;

      try {
        const response = await fetch('/api/todos');
        if (!response.ok) {
          throw new Error(
            `HTTP error! status: ${response.status}`
          );
        }
        const todos = await response.json();
        this.todos = todos;
      } catch (error) {
        this.error =
          error instanceof Error
            ? error.message
            : 'Unknown error';
        console.error('Failed to fetch todos:', error);
      } finally {
        this.loading = false;
      }
    },

    // 新しいTodoをサーバーに保存
    async saveTodo(text: string) {
      this.loading = true;

      try {
        const response = await fetch('/api/todos', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ text, completed: false }),
        });

        if (!response.ok) {
          throw new Error('Failed to save todo');
        }

        const newTodo = await response.json();
        this.todos.push(newTodo);
      } catch (error) {
        this.error =
          error instanceof Error
            ? error.message
            : 'Save failed';
        throw error; // コンポーネント側でエラーハンドリングできるよう再throw
      } finally {
        this.loading = false;
      }
    },
  },
});

コンポーネントでの利用方法

実際のコンポーネントで Pinia ストアを活用する方法を総合的に確認しましょう。

typescript<!-- components/TodoApp.vue -->
<template>
  <div class="todo-app">
    <h1>Todo アプリケーション</h1>

    <!-- 新しいTodo追加フォーム -->
    <form @submit.prevent="handleAddTodo">
      <input
        v-model="newTodoText"
        placeholder="新しいタスクを入力"
        :disabled="loading"
      >
      <button type="submit" :disabled="loading || !newTodoText.trim()">
        {{ loading ? '保存中...' : '追加' }}
      </button>
    </form>

    <!-- エラー表示 -->
    <div v-if="error" class="error">
      エラー: {{ error }}
    </div>

    <!-- 統計情報 -->
    <div class="stats">
      <p>総数: {{ todoCount }}</p>
      <p>完了: {{ completedTodos.length }}</p>
      <p>未完了: {{ pendingTodos.length }}</p>
    </div>

    <!-- Todo リスト -->
    <div class="todo-list">
      <div
        v-for="todo in todos"
        :key="todo.id"
        class="todo-item"
        :class="{ completed: todo.completed }"
      >
        <input
          type="checkbox"
          :checked="todo.completed"
          @change="toggleTodo(todo.id)"
        >
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">削除</button>
      </div>
    </div>
  </div>
</template>
typescript<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useTodoStore } from '@/stores/todo'

// ストアの使用
const todoStore = useTodoStore()

// リアクティブな状態の分割代入
const {
  todos,
  completedTodos,
  pendingTodos,
  todoCount,
  loading,
  error
} = storeToRefs(todoStore)

// アクションの分割代入
const {
  addTodo,
  toggleTodo,
  removeTodo,
  fetchTodos,
  saveTodo
} = todoStore

// ローカルstate
const newTodoText = ref('')

// イベントハンドラー
const handleAddTodo = async () => {
  if (!newTodoText.value.trim()) return

  try {
    await saveTodo(newTodoText.value)
    newTodoText.value = '' // 成功時にフォームをクリア
  } catch (error) {
    // エラーはストア内で処理されるため、ここでは追加の処理のみ
    console.error('Todo追加に失敗しました')
  }
}

// 初期データの読み込み
onMounted(() => {
  fetchTodos()
})
</script>

この例では、状態の読み取りアクションの実行エラーハンドリングローディング状態の管理など、実際のアプリケーションで必要となる要素を包括的に含んでいます。

複数ストアの組み合わせも実際の開発でよく使用されるパターンです。

typescript<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { useTodoStore } from '@/stores/todo'
import { useNotificationStore } from '@/stores/notification'

// 複数のストアを同時に使用
const userStore = useUserStore()
const todoStore = useTodoStore()
const notificationStore = useNotificationStore()

const handleTodoComplete = async (todoId: number) => {
  try {
    await todoStore.toggleTodo(todoId)
    notificationStore.showSuccess('タスクを完了しました!')
  } catch (error) {
    notificationStore.showError('タスクの更新に失敗しました')
  }
}
</script>

まとめ

Pinia は、Vue.js アプリケーションの状態管理を革新的にシンプル化した次世代ライブラリです。従来の Vuex と比較して、ボイラープレートコードの大幅な削減、TypeScript との完全な統合、直感的な API デザインを実現しています。

この記事で学んだ主要なポイントは以下の通りです。

基本概念の理解: State、Getters、Actions の 3 つの核となる概念により、明確で理解しやすい構造を持っています。mutations を廃止したことで、学習コストが大幅に削減されました。

実装の簡潔性: defineStoreによる直感的なストア定義、storeToRefsを使ったリアクティブな分割代入、シンプルなアクション実装により、開発効率が向上します。

TypeScript 対応: 自動型推論と IntellSense サポートにより、大規模開発での型安全性が確保されています。コンパイル時のエラー検出により、バグの早期発見が可能です。

開発体験の向上: Vue DevTools との完全統合、ホットリロード対応、テスト支援機能により、開発者の生産性が大幅に向上しています。

Pinia の導入により、Vue.js アプリケーションの状態管理がより直感的で保守しやすくなります。小規模なプロジェクトから大規模なエンタープライズアプリケーションまで、あらゆる規模の開発に対応できる柔軟性を備えています。

ぜひ次のプロジェクトで Pinia を試してみてください。きっとその使いやすさとパワフルさに驚かれることでしょう。

関連リンク