T-CREATOR

Pinia の基本 API 解説:defineStore・state・getters・actions

Pinia の基本 API 解説:defineStore・state・getters・actions

Vue.js アプリケーション開発において、コンポーネント間でのデータ共有は避けて通れない課題です。小規模なアプリケーションではコンポーネント間の親子関係で解決できますが、規模が大きくなるにつれて状態管理の複雑さが増していきます。

Pinia は Vue.js の公式状態管理ライブラリとして、シンプルで直感的な API を提供します。この記事では、Pinia の核となる 4 つの基本 API(defineStore、state、getters、actions)について、実際の Todo アプリケーションを通じて段階的に学んでいきます。

背景:Vue.js における状態管理の必要性

現代の Web アプリケーション開発では、ユーザーインターフェースの複雑化に伴い、複数のコンポーネント間でデータを共有する必要性が高まっています。

mermaidflowchart TD
    user[ユーザー] -->|操作| component1[ヘッダーコンポーネント]
    user -->|操作| component2[サイドバーコンポーネント]
    user -->|操作| component3[メインコンポーネント]

    component1 -->|データ共有| shared[共有状態]
    component2 -->|データ共有| shared
    component3 -->|データ共有| shared

    shared -->|更新通知| component1
    shared -->|更新通知| component2
    shared -->|更新通知| component3

上図は、複数のコンポーネントが共通の状態を参照・更新する典型的なパターンを示しています。各コンポーネントが独立して状態を更新し、その変更が他のコンポーネントに即座に反映される必要があります。

複雑な状態管理が必要な場面

Vue.js アプリケーションで状態管理が特に重要になるのは以下のようなケースです:

#場面具体例課題
1ユーザー認証情報ログイン状態、ユーザープロフィール全画面で参照が必要
2ショッピングカート商品数、合計金額複数画面での同期が必要
3通知システムエラーメッセージ、成功通知任意のタイミングでの表示制御
4リアルタイム更新チャット、ライブデータ複数コンポーネントでの即時反映

Vue.js の標準的なデータ共有手法

Vue.js では、親子コンポーネント間でのデータ共有に props と emit を使用します:

javascript// 親コンポーネント
<template>
  <ChildComponent :message="parentMessage" @update="handleUpdate" />
</template>

<script>
export default {
  data() {
    return {
      parentMessage: 'Hello from parent'
    }
  },
  methods: {
    handleUpdate(newValue) {
      this.parentMessage = newValue
    }
  }
}
</script>
javascript// 子コンポーネント
<template>
  <div>
    <p>{{ message }}</p>
    <button @click="updateMessage">Update</button>
  </div>
</template>

<script>
export default {
  props: ['message'],
  emits: ['update'],
  methods: {
    updateMessage() {
      this.$emit('update', 'Updated message')
    }
  }
}
</script>

このアプローチは単純な親子関係では有効ですが、複雑な構造では限界があります。

課題:従来の状態管理手法の限界

従来の Vue.js における状態管理手法には、アプリケーションの規模拡大に伴い顕在化する課題がいくつか存在します。

Prop Drilling の問題

深いコンポーネント階層において、中間コンポーネントが不要なデータを受け渡すことになる問題です:

mermaidflowchart TD
    grand[祖父コンポーネント] -->|props| parent[父コンポーネント]
    parent -->|props| child[子コンポーネント]
    child -->|props| target[目標コンポーネント]

    grand -.->|本当に必要| target
    parent -.->|不要な中継| parent
    child -.->|不要な中継| child

上図のように、実際にデータが必要なのは目標コンポーネントだけなのに、中間のコンポーネントが不要な props を受け取り続けることになります。

javascript// 祖父コンポーネント
<template>
  <ParentComponent :user-data="userData" />
</template>

// 父コンポーネント(userDataを使わないが中継が必要)
<template>
  <ChildComponent :user-data="userData" />
</template>

// 子コンポーネント(userDataを使わないが中継が必要)
<template>
  <GrandChildComponent :user-data="userData" />
</template>

// 孫コンポーネント(実際にuserDataが必要)
<template>
  <div>{{ userData.name }}</div>
</template>

Event Bubbling の複雑化

深い階層からのイベント伝播も同様に複雑になります:

javascript// 孫コンポーネント
methods: {
  updateUser() {
    this.$emit('update-user', newUserData)
  }
}

// 子コンポーネント(単なる中継)
<template>
  <GrandChild @update-user="$emit('update-user', $event)" />
</template>

// 父コンポーネント(単なる中継)
<template>
  <Child @update-user="$emit('update-user', $event)" />
</template>

// 祖父コンポーネント(実際の処理)
<template>
  <Parent @update-user="handleUpdateUser" />
</template>

コンポーネント間の密結合

兄弟コンポーネント間でのデータ共有では、親コンポーネントを経由する必要があり、関係性が複雑になります:

mermaidsequenceDiagram
    participant A as コンポーネントA
    participant Parent as 親コンポーネント
    participant B as コンポーネントB

    A->>Parent: データ更新イベント
    Parent->>Parent: 状態更新
    Parent->>B: 新しいpropsを送信
    B->>B: 画面を再描画

主な課題の整理

#課題問題点影響
1Prop Drilling不要なコンポーネントでも props を受け取る保守性の低下、コードの可読性悪化
2Event Bubbling中間コンポーネントでの不要な中継処理処理の複雑化、デバッグの困難
3密結合コンポーネント間の強い依存関係再利用性の低下、テストの困難
4状態の散在複数箇所での状態管理整合性の確保が困難

これらの課題を解決するために、集中化された状態管理ライブラリが必要となります。

解決策:Pinia の基本 API 概要

Pinia は Vue.js チームが開発した現代的な状態管理ライブラリです。Vuex の後継として設計され、より直感的で型安全な API を提供します。

mermaidflowchart LR
    subgraph "Pinia Store"
        state[State<br/>状態データ]
        getters[Getters<br/>算出プロパティ]
        actions[Actions<br/>状態変更ロジック]
    end

    component1[コンポーネントA] -->|参照・更新| state
    component2[コンポーネントB] -->|参照・更新| state
    component3[コンポーネントC] -->|参照・更新| state

    state --> getters
    actions --> state

上図に示すように、Pinia は中央集権的な状態管理を実現し、すべてのコンポーネントが同一のストアにアクセスできます。

Pinia の 4 つの基本 API

Pinia の核となる機能は 4 つの基本 API で構成されています:

#API 名役割特徴
1defineStoreストアの定義TypeScript サポート、柔軟な定義方式
2state状態の管理リアクティブなデータ保持
3getters派生データの算出Vue の computed プロパティ相当
4actionsビジネスロジック実行同期・非同期処理の統一的な記述

defineStore の役割と基本構造

defineStore は Pinia ストアを定義するための関数です。ユニークなストア ID と設定オブジェクトを受け取り、ストアインスタンスを返します。

javascriptimport { defineStore } from 'pinia';

// 基本的なストア定義
export const useCounterStore = defineStore('counter', {
  // ストアの設定をここに記述
});

Options API 形式でのストア定義

Vue 2 の Options API に慣れている開発者向けの記述方式です:

javascriptexport const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'カウンター',
  }),

  getters: {
    doubleCount: (state) => state.count * 2,
    displayName: (state) => `${state.name}: ${state.count}`,
  },

  actions: {
    increment() {
      this.count++;
    },

    async fetchInitialCount() {
      // 非同期処理の例
      const response = await fetch('/api/initial-count');
      const data = await response.json();
      this.count = data.count;
    },
  },
});

Composition API 形式でのストア定義

Vue 3 の Composition API に対応した、より自由度の高い記述方式です:

javascriptimport { ref, computed } from 'vue';

export const useCounterStore = defineStore(
  'counter',
  () => {
    // state の定義
    const count = ref(0);
    const name = ref('カウンター');

    // getters の定義
    const doubleCount = computed(() => count.value * 2);
    const displayName = computed(
      () => `${name.value}: ${count.value}`
    );

    // actions の定義
    function increment() {
      count.value++;
    }

    async function fetchInitialCount() {
      const response = await fetch('/api/initial-count');
      const data = await response.json();
      count.value = data.count;
    }

    return {
      count,
      name,
      doubleCount,
      displayName,
      increment,
      fetchInitialCount,
    };
  }
);

state:状態の定義と管理

state はストア内でリアクティブなデータを管理する仕組みです。Vue の data オプションに相当し、コンポーネント間で共有される状態を定義します。

基本的な state の定義

javascriptexport const useUserStore = defineStore('user', {
  state: () => ({
    // プリミティブな値
    id: null,
    name: '',
    email: '',
    isLoggedIn: false,

    // オブジェクト
    profile: {
      avatar: '',
      bio: '',
      preferences: {
        theme: 'light',
        language: 'ja',
      },
    },

    // 配列
    posts: [],
    notifications: [],
  }),
});

TypeScript での型安全な state 定義

型安全性を確保するため、interface を定義してから state を作成します:

javascriptinterface User {
  id: number | null
  name: string
  email: string
  isLoggedIn: boolean
}

interface UserState {
  user: User
  posts: Post[]
  notifications: Notification[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    user: {
      id: null,
      name: '',
      email: '',
      isLoggedIn: false
    },
    posts: [],
    notifications: []
  })
})

getters:算出プロパティとしての活用

getters は state に基づいて算出される派生データを定義します。Vue の computed プロパティと同様の仕組みで、依存する state が変更された場合のみ再計算されます。

基本的な getters の使用

javascriptexport const useShoppingStore = defineStore('shopping', {
  state: () => ({
    items: [
      { id: 1, name: '商品A', price: 1000, quantity: 2 },
      { id: 2, name: '商品B', price: 1500, quantity: 1 },
    ],
  }),

  getters: {
    // 単純な算出プロパティ
    totalItems: (state) => state.items.length,

    // 配列の要素を使った計算
    totalPrice: (state) => {
      return state.items.reduce((sum, item) => {
        return sum + item.price * item.quantity;
      }, 0);
    },

    // フォーマット済みのデータ
    formattedTotal: (state) => {
      const total = state.items.reduce((sum, item) => {
        return sum + item.price * item.quantity;
      }, 0);
      return ${total.toLocaleString()}`;
    },
  },
});

他の getters に依存する getters

javascriptgetters: {
  totalPrice: (state) => {
    return state.items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
  },

  taxAmount: (state) => {
    // this を使用して他の getter にアクセス
    return Math.floor(this.totalPrice * 0.1)
  },

  finalPrice() {
    // this を使って複数の getter を組み合わせ
    return this.totalPrice + this.taxAmount
  }
}

引数付き getters

特定の条件でフィルタリングしたデータを取得する場合に使用します:

javascriptgetters: {
  // 引数付き getter(関数を返す)
  getItemById: (state) => (id) => {
    return state.items.find(item => item.id === id)
  },

  getItemsByCategory: (state) => (category) => {
    return state.items.filter(item => item.category === category)
  },

  getPriceRange: (state) => (min, max) => {
    return state.items.filter(item => item.price >= min && item.price <= max)
  }
}

actions:状態変更とビジネスロジック

actions は state の値を変更し、ビジネスロジックを実行するためのメソッドです。Vue の methods オプションに相当し、同期・非同期の処理を統一的に記述できます。

基本的な actions の定義

javascriptexport const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    history: [],
  }),

  actions: {
    // 同期的な state 変更
    increment() {
      this.count++;
      this.history.push({
        action: 'increment',
        timestamp: Date.now(),
      });
    },

    decrement() {
      this.count--;
      this.history.push({
        action: 'decrement',
        timestamp: Date.now(),
      });
    },

    // 引数を受け取る action
    incrementBy(amount) {
      this.count += amount;
      this.history.push({
        action: 'incrementBy',
        amount,
        timestamp: Date.now(),
      });
    },

    // 複雑なロジックを含む action
    reset() {
      const previousCount = this.count;
      this.count = 0;
      this.history.push({
        action: 'reset',
        previousCount,
        timestamp: Date.now(),
      });
    },
  },
});

非同期処理を含む actions

API 通信やタイマー処理などの非同期処理も actions で管理できます:

javascriptexport const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false,
    error: null,
  }),

  actions: {
    async fetchUser(userId) {
      // ローディング状態の設定
      this.loading = true;
      this.error = null;

      try {
        const response = await fetch(
          `/api/users/${userId}`
        );

        if (!response.ok) {
          throw new Error(
            `HTTP error! status: ${response.status}`
          );
        }

        const userData = await response.json();

        // 成功時の state 更新
        this.user = userData;
      } catch (error) {
        // エラー時の state 更新
        this.error = error.message;
        console.error('ユーザー取得エラー:', error);
      } finally {
        // 必ずローディング状態を解除
        this.loading = false;
      }
    },

    async updateUser(userUpdates) {
      this.loading = true;

      try {
        const response = await fetch(
          `/api/users/${this.user.id}`,
          {
            method: 'PUT',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify(userUpdates),
          }
        );

        if (response.ok) {
          const updatedUser = await response.json();
          this.user = updatedUser;
        }
      } catch (error) {
        this.error = error.message;
        throw error; // 呼び出し元でも処理できるよう再throw
      } finally {
        this.loading = false;
      }
    },
  },
});

複数の actions を組み合わせる

javascriptactions: {
  async login(credentials) {
    try {
      const user = await this.authenticate(credentials)
      await this.fetchUserProfile(user.id)
      await this.loadUserPreferences()

      return user
    } catch (error) {
      this.handleLoginError(error)
      throw error
    }
  },

  async authenticate(credentials) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credentials)
    })

    if (!response.ok) {
      throw new Error('認証に失敗しました')
    }

    const user = await response.json()
    this.user = user
    return user
  },

  handleLoginError(error) {
    this.error = error.message
    this.user = null
  }
}

具体例:Todo アプリで学ぶ基本 API

実際の Todo アプリケーションを通じて、Pinia の 4 つの基本 API の使い方を段階的に学んでいきます。このセクションでは、プロジェクトの構築から完成まで、実践的な実装例を示します。

プロジェクト環境構築

まず、Vue 3 と Pinia を使用したプロジェクトの環境を構築します。

プロジェクトの初期化

bash# Vue.js プロジェクトを作成
yarn create vue@latest todo-pinia-app

# プロジェクトディレクトリに移動
cd todo-pinia-app

# 依存関係のインストール
yarn install

# Pinia の追加
yarn add pinia

Pinia のセットアップ

main.js ファイルで Pinia を Vue アプリケーションに登録します:

javascript// main.js
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');

プロジェクト構造の確認

csssrc/
├── main.js
├── App.vue
├── components/
│   ├── TodoList.vue
│   ├── TodoItem.vue
│   └── TodoForm.vue
└── stores/
    └── todo.js

defineStore でストア作成

Todo アプリケーション用のストアを defineStore を使用して作成します。

基本的なストア定義

javascript// stores/todo.js
import { defineStore } from 'pinia';

export const useTodoStore = defineStore('todo', {
  // ここにストアの設定を記述していきます
});

Todo データの型定義

TypeScript を使用する場合の型定義を行います:

javascript// stores/todo.js
interface Todo {
  id: number
  text: string
  completed: boolean
  createdAt: Date
  updatedAt: Date
}

interface Filter {
  ALL: 'all'
  ACTIVE: 'active'
  COMPLETED: 'completed'
}

// フィルターの定数定義
const FILTERS = {
  ALL: 'all',
  ACTIVE: 'active',
  COMPLETED: 'completed'
}

ストアの基本構造作成

javascriptexport const useTodoStore = defineStore('todo', {
  state: () => ({
    // ここで初期状態を定義
    todos: [],
    filter: 'all',
    nextId: 1,
  }),

  getters: {
    // 算出プロパティをここに定義
  },

  actions: {
    // 状態変更のロジックをここに定義
  },
});

state で初期データ定義

ストアで管理する状態データを定義します。Todo アプリケーションに必要な状態を整理して実装していきます。

基本的な state の実装

javascriptexport const useTodoStore = defineStore('todo', {
  state: () => ({
    // Todo リストのデータ
    todos: [
      {
        id: 1,
        text: 'Pinia について学ぶ',
        completed: false,
        createdAt: new Date('2024-01-01T10:00:00'),
        updatedAt: new Date('2024-01-01T10:00:00'),
      },
      {
        id: 2,
        text: 'Vue.js アプリを作成する',
        completed: true,
        createdAt: new Date('2024-01-01T11:00:00'),
        updatedAt: new Date('2024-01-01T12:00:00'),
      },
    ],

    // フィルター状態
    currentFilter: 'all', // 'all', 'active', 'completed'

    // UI 状態
    loading: false,
    error: null,

    // ID管理用
    nextId: 3,
  }),
});

リアクティビティの確認

state で定義されたデータは、Vue のリアクティブシステムにより自動的に変更を検出します:

javascript// コンポーネント内での使用例
<template>
  <div>
    <p>Todo数: {{ todoStore.todos.length }}</p>
    <p>現在のフィルター: {{ todoStore.currentFilter }}</p>
  </div>
</template>

<script setup>
import { useTodoStore } from '@/stores/todo'

const todoStore = useTodoStore()

// state の値が変更されると、テンプレートも自動的に更新される
</script>

ネストされたオブジェクトの管理

複雑なデータ構造も state で管理できます:

javascriptstate: () => ({
  todos: [],

  // 設定データ
  settings: {
    autoSave: true,
    theme: 'light',
    sortBy: 'createdAt', // 'createdAt', 'text', 'completed'
    sortOrder: 'desc', // 'asc', 'desc'
  },

  // 統計データ
  statistics: {
    totalCreated: 0,
    totalCompleted: 0,
    averageCompletionTime: 0,
  },
});

getters で派生データ取得

state から派生するデータや、フィルタリングされたデータを getters で定義します。

基本的な getters の実装

javascriptexport const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [
      { id: 1, text: 'タスク1', completed: false },
      { id: 2, text: 'タスク2', completed: true },
      { id: 3, text: 'タスク3', completed: false },
    ],
    currentFilter: 'all',
  }),

  getters: {
    // 完了済みTodoの数
    completedCount: (state) => {
      return state.todos.filter((todo) => todo.completed)
        .length;
    },

    // 未完了Todoの数
    activeCount: (state) => {
      return state.todos.filter((todo) => !todo.completed)
        .length;
    },

    // 総Todo数
    totalCount: (state) => state.todos.length,

    // 完了率(パーセンテージ)
    completionRate: (state) => {
      if (state.todos.length === 0) return 0;
      const completed = state.todos.filter(
        (todo) => todo.completed
      ).length;
      return Math.round(
        (completed / state.todos.length) * 100
      );
    },
  },
});

フィルタリング機能の実装

現在のフィルター設定に応じて Todo リストを絞り込む getters を実装します:

javascriptgetters: {
  // フィルター適用済みのTodoリスト
  filteredTodos: (state) => {
    switch (state.currentFilter) {
      case 'active':
        return state.todos.filter(todo => !todo.completed)
      case 'completed':
        return state.todos.filter(todo => todo.completed)
      case 'all':
      default:
        return state.todos
    }
  },

  // フィルター別の件数情報
  filterCounts: (state) => ({
    all: state.todos.length,
    active: state.todos.filter(todo => !todo.completed).length,
    completed: state.todos.filter(todo => todo.completed).length
  }),

  // 現在のフィルターの表示名
  currentFilterLabel: (state) => {
    const labels = {
      all: 'すべて',
      active: '未完了',
      completed: '完了済み'
    }
    return labels[state.currentFilter] || 'すべて'
  }
}

ソート機能の実装

設定に基づいて Todo をソートする getters を追加します:

javascriptgetters: {
  // ソート済みのTodoリスト
  sortedTodos() {
    const todos = [...this.filteredTodos]
    const { sortBy, sortOrder } = this.settings

    todos.sort((a, b) => {
      let comparison = 0

      switch (sortBy) {
        case 'text':
          comparison = a.text.localeCompare(b.text)
          break
        case 'createdAt':
          comparison = new Date(a.createdAt) - new Date(b.createdAt)
          break
        case 'completed':
          comparison = a.completed - b.completed
          break
        default:
          comparison = a.id - b.id
      }

      return sortOrder === 'desc' ? -comparison : comparison
    })

    return todos
  },

  // 検索機能付きの getters(引数付き)
  searchTodos: (state) => (query) => {
    if (!query) return state.todos

    const lowerQuery = query.toLowerCase()
    return state.todos.filter(todo =>
      todo.text.toLowerCase().includes(lowerQuery)
    )
  }
}

コンポーネントでの getters 使用例

javascript<template>
  <div class="todo-summary">
    <h3>{{ todoStore.currentFilterLabel }}のタスク</h3>
    <p>{{ todoStore.filteredTodos.length }}件のタスク</p>
    <p>完了率: {{ todoStore.completionRate }}%</p>

    <div class="filter-tabs">
      <button
        v-for="(count, filter) in todoStore.filterCounts"
        :key="filter"
        @click="todoStore.setFilter(filter)"
        :class="{ active: todoStore.currentFilter === filter }"
      >
        {{ getFilterLabel(filter) }} ({{ count }})
      </button>
    </div>

    <ul>
      <li
        v-for="todo in todoStore.sortedTodos"
        :key="todo.id"
        :class="{ completed: todo.completed }"
      >
        {{ todo.text }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { useTodoStore } from '@/stores/todo'

const todoStore = useTodoStore()

function getFilterLabel(filter) {
  const labels = { all: 'すべて', active: '未完了', completed: '完了済み' }
  return labels[filter]
}
</script>

actions で CRUD 操作実装

Todo アプリケーションの基本的な CRUD(Create, Read, Update, Delete)操作を actions で実装します。

作成(Create)操作の実装

新しい Todo を追加する action を実装します:

javascriptexport const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [],
    nextId: 1,
    loading: false,
    error: null,
  }),

  actions: {
    // Todo追加
    addTodo(text) {
      // 入力値の検証
      if (!text || text.trim().length === 0) {
        this.error = 'Todo のテキストを入力してください';
        return false;
      }

      // 新しいTodoオブジェクトの作成
      const newTodo = {
        id: this.nextId++,
        text: text.trim(),
        completed: false,
        createdAt: new Date(),
        updatedAt: new Date(),
      };

      // 配列の先頭に追加(最新のものが上に表示される)
      this.todos.unshift(newTodo);

      // エラーをクリア
      this.error = null;

      // 統計の更新
      this.statistics.totalCreated++;

      return true;
    },

    // 複数のTodoを一括追加
    addMultipleTodos(todoTexts) {
      const addedTodos = [];

      todoTexts.forEach((text) => {
        if (text && text.trim().length > 0) {
          const newTodo = {
            id: this.nextId++,
            text: text.trim(),
            completed: false,
            createdAt: new Date(),
            updatedAt: new Date(),
          };
          addedTodos.push(newTodo);
        }
      });

      // 一度に配列に追加
      this.todos.unshift(...addedTodos);
      this.statistics.totalCreated += addedTodos.length;

      return addedTodos.length;
    },
  },
});

更新(Update)操作の実装

既存の Todo の内容や状態を変更する action を実装します:

javascriptactions: {
  // Todo完了状態の切り替え
  toggleTodo(id) {
    const todo = this.todos.find(t => t.id === id)

    if (!todo) {
      this.error = `ID: ${id} の Todo が見つかりません`
      return false
    }

    const wasCompleted = todo.completed
    todo.completed = !todo.completed
    todo.updatedAt = new Date()

    // 統計の更新
    if (!wasCompleted && todo.completed) {
      this.statistics.totalCompleted++
    } else if (wasCompleted && !todo.completed) {
      this.statistics.totalCompleted--
    }

    this.error = null
    return true
  },

  // Todoテキストの編集
  editTodo(id, newText) {
    if (!newText || newText.trim().length === 0) {
      this.error = 'Todo のテキストを入力してください'
      return false
    }

    const todo = this.todos.find(t => t.id === id)

    if (!todo) {
      this.error = `ID: ${id} の Todo が見つかりません`
      return false
    }

    todo.text = newText.trim()
    todo.updatedAt = new Date()
    this.error = null

    return true
  },

  // 全てのTodoを完了/未完了にする
  toggleAllTodos(completed = true) {
    let changedCount = 0

    this.todos.forEach(todo => {
      if (todo.completed !== completed) {
        todo.completed = completed
        todo.updatedAt = new Date()
        changedCount++
      }
    })

    // 統計の更新
    if (completed) {
      this.statistics.totalCompleted = this.todos.length
    } else {
      this.statistics.totalCompleted = 0
    }

    return changedCount
  }
}

削除(Delete)操作の実装

Todo を削除する action を実装します:

javascriptactions: {
  // 特定のTodoを削除
  deleteTodo(id) {
    const index = this.todos.findIndex(t => t.id === id)

    if (index === -1) {
      this.error = `ID: ${id} の Todo が見つかりません`
      return false
    }

    const deletedTodo = this.todos[index]
    this.todos.splice(index, 1)

    // 統計の更新
    if (deletedTodo.completed) {
      this.statistics.totalCompleted--
    }

    this.error = null
    return true
  },

  // 完了済みのTodoを全て削除
  clearCompleted() {
    const completedCount = this.todos.filter(t => t.completed).length

    this.todos = this.todos.filter(todo => !todo.completed)
    this.statistics.totalCompleted = 0

    return completedCount
  },

  // 全てのTodoを削除
  clearAllTodos() {
    const deletedCount = this.todos.length

    this.todos = []
    this.statistics.totalCompleted = 0
    this.nextId = 1

    return deletedCount
  }
}

非同期操作の実装

API との連携を想定した非同期の CRUD 操作を実装します:

javascriptactions: {
  // サーバーから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
      this.nextId = Math.max(...todos.map(t => t.id)) + 1

      // 統計の再計算
      this.statistics.totalCreated = todos.length
      this.statistics.totalCompleted = todos.filter(t => t.completed).length

    } catch (error) {
      this.error = `Todo取得エラー: ${error.message}`
      console.error('fetchTodos error:', error)

    } finally {
      this.loading = false
    }
  },

  // サーバーにTodoを保存
  async saveTodo(todo) {
    this.loading = true

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

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }

      const savedTodo = await response.json()

      // ローカル状態を更新
      const index = this.todos.findIndex(t => t.id === todo.id)
      if (index !== -1) {
        this.todos[index] = savedTodo
      }

      return savedTodo

    } catch (error) {
      this.error = `Todo保存エラー: ${error.message}`
      throw error

    } finally {
      this.loading = false
    }
  }
}

フィルター管理の action

UI の状態管理も actions で実装します:

javascriptactions: {
  // フィルターの変更
  setFilter(filter) {
    const validFilters = ['all', 'active', 'completed']

    if (!validFilters.includes(filter)) {
      this.error = `無効なフィルター: ${filter}`
      return false
    }

    this.currentFilter = filter
    this.error = null
    return true
  },

  // 設定の更新
  updateSettings(newSettings) {
    this.settings = {
      ...this.settings,
      ...newSettings
    }
  },

  // エラーのクリア
  clearError() {
    this.error = null
  }
}

コンポーネントでの actions 使用例

javascript<template>
  <div class="todo-app">
    <form @submit.prevent="handleAddTodo">
      <input
        v-model="newTodoText"
        placeholder="新しいタスクを入力"
        :disabled="todoStore.loading"
      />
      <button type="submit" :disabled="todoStore.loading">
        追加
      </button>
    </form>

    <div v-if="todoStore.error" class="error">
      {{ todoStore.error }}
      <button @click="todoStore.clearError">×</button>
    </div>

    <div v-if="todoStore.loading" class="loading">
      読み込み中...
    </div>

    <ul class="todo-list">
      <li v-for="todo in todoStore.sortedTodos" :key="todo.id">
        <input
          type="checkbox"
          :checked="todo.completed"
          @change="todoStore.toggleTodo(todo.id)"
        />
        <span :class="{ completed: todo.completed }">
          {{ todo.text }}
        </span>
        <button @click="todoStore.deleteTodo(todo.id)">
          削除
        </button>
      </li>
    </ul>

    <div class="actions">
      <button @click="todoStore.toggleAllTodos(true)">
        全て完了
      </button>
      <button @click="todoStore.clearCompleted()">
        完了済みを削除
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useTodoStore } from '@/stores/todo'

const todoStore = useTodoStore()
const newTodoText = ref('')

// 新しいTodoの追加
function handleAddTodo() {
  if (todoStore.addTodo(newTodoText.value)) {
    newTodoText.value = ''
  }
}

// コンポーネントマウント時にデータを取得
onMounted(() => {
  todoStore.fetchTodos()
})
</script>

この Todo アプリケーションの実装により、Pinia の 4 つの基本 API の実践的な使い方を学習できます。各 API が連携してリアクティブな状態管理を実現している様子を確認できるでしょう。

まとめ

この記事では、Pinia の 4 つの基本 API(defineStore、state、getters、actions)について、理論から実践まで段階的に学習しました。

Pinia の基本 API のポイント

API役割特徴使用場面
defineStoreストアの定義型安全性、柔軟な記述方式ストア作成時
state状態データの管理リアクティブ、ネスト対応データ保持
getters派生データの算出キャッシュ機能、依存関係計算処理
actionsビジネスロジック実行同期・非同期統一状態変更

Vue.js 開発における Pinia のメリット

Pinia を使用することで、従来の Vue.js 開発における課題を効果的に解決できます:

解決される課題:

  • Prop Drilling による煩雑なデータ受け渡し
  • Event Bubbling の複雑な中継処理
  • コンポーネント間の密結合
  • 状態管理の分散による整合性の問題

得られる効果:

  • 中央集権的な状態管理によるコードの整理
  • TypeScript との優れた連携による型安全性
  • 直感的な API による学習コストの軽減
  • Vue DevTools との統合によるデバッグ支援

実装時の重要なポイント

Todo アプリケーションの実装を通じて学んだ重要なポイントを整理します:

state 設計時:

  • 初期値を適切に設定する
  • ネストしたオブジェクトも適切に管理される
  • TypeScript との組み合わせで型安全性を確保

getters 活用時:

  • 計算処理は getters で実装し、キャッシュ機能を活用する
  • 他の getters への依存関係も明確に管理できる
  • 引数付き getters でフィルタリング機能を実現

actions 実装時:

  • 同期・非同期処理を統一的に記述できる
  • エラーハンドリングとローディング状態の管理を忘れずに
  • 複数の actions を組み合わせて複雑な処理を実現

次のステップ

Pinia の基本 API を習得した後は、以下の発展的なトピックに取り組むことをお勧めします:

  • プラグインシステム:カスタムプラグインの作成と活用
  • パフォーマンス最適化:大規模アプリケーションでの最適化手法
  • テスト戦略:Pinia を使用したアプリケーションのテスト方法
  • SSR/SSG 対応:Nuxt.js との連携による Server-Side Rendering

Pinia は Vue.js エコシステムの中核を担う状態管理ライブラリとして、今後も継続的に発展していくでしょう。基本 API をしっかりと理解することで、より効率的で保守しやすい Vue.js アプリケーションの開発が可能になります。

関連リンク