Pinia — グローバル状態管理

この章では、Pinia を使ってコンポーネントやページをまたぐ共有データを管理し、「記事のお気に入り」機能を実装します。


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

これまで、コンポーネント間のデータは props と emit で渡してきました。

これは親子関係が単純なケースでは十分ですが、次のような状況では対応が難しくなります:

  • お気に入り状態をホームページと詳細ページで同期する必要がある
  • ユーザーのログイン状態をすべてのページで共有する必要がある
  • カートのデータをナビバー、商品ページ、決済ページの間で受け渡す必要がある

props の階層ごとの受け渡しの課題:A コンポーネントのデータを D コンポーネントに渡すには、B、C の2層を経由する必要があり、中間のコンポーネント自身はこのデータを必要としません。

PiniaPinia は Vue3 公式推奨の状態管理ライブラリで、グローバルな「データセンター」を提供します。どのコンポーネントからでも直接読み書きでき、階層ごとの受け渡しは不要です。


Pinia のインストールと登録

プロジェクト作成時に Pinia を選択した場合は、インストール手順をスキップしてください。

それ以外の場合は:

$ npm install pinia

main.js で登録:

例

// ファイルパス:src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)

// Pinia インスタンスを作成して登録
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.mount('#app')

defineStore — ストアの定義

Pinia の中核はStore(ストア)であり、各 Store が独立した状態を管理します。

ストアには次の3つの部分があります:

部分役割対応
stateデータの保存コンポーネントの data / ref
getters派生計算データコンポーネントの computed
actionsデータを変更するメソッドコンポーネントの methods / function

例

// ファイルパス:src/stores/useFavoriteStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// defineStore('ストア名', () => { ... })
// 第2引数は関数形式で、Composition API のスタイルに準拠
export const useFavoriteStore = defineStore('favorite', () => {
  // ====== state:ref で状態を定義 ======
  // お気に入りの記事 ID を配列で保存する
  const favoriteIds = ref([])

  // localStorage からお気に入りリストを復元
  const saved = localStorage.getItem('blog-favorites')
  if (saved) {
    favoriteIds.value = JSON.parse(saved)
  }

  // ====== getters:computed で算出プロパティを定義 ======
  const favoriteCount = computed(() => favoriteIds.value.length)

  // 指定した記事がお気に入り済みかどうかを判定
  function isFavorite(articleId) {
    return favoriteIds.value.includes(articleId)
  }

  // ====== actions:通常の関数で変更メソッドを定義 ======
  // お気に入り状態を切り替える(お気に入り → 解除、未お気に入り → お気に入り)
  function toggleFavorite(articleId) {
    const index = favoriteIds.value.indexOf(articleId)
    if (index === -1) {
      favoriteIds.value.push(articleId)     // お気に入りに追加
    } else {
      favoriteIds.value.splice(index, 1)    // お気に入りを解除
    }
    // localStorage に同期(永続化)
    localStorage.setItem('blog-favorites', JSON.stringify(favoriteIds.value))
  }

  return { favoriteIds, favoriteCount, isFavorite, toggleFavorite }
})

defineStore の第2引数には2つの書き方があります:Options Store(Vue2 の data/methods/computed のオブジェクト形式に類似した書き方)とSetup Store(Composition API を使用する関数形式)です。Setup Store の使用を推奨します。コンポーネント内の<script setup>書き方と完全に一致するため、学習コストが最も低くなります。


コンポーネントで Store を使用する

どのコンポーネントでも、useFavoriteStore()ストアインスタンスを取得し、state を直接読み書きして action を呼び出すことができます。

BlogCard にお気に入りボタンを追加する

例

<!-- ファイルパス:src/components/BlogCard.vue -->
<script setup>
import { useFavoriteStore } from '../stores/useFavoriteStore.js'

const props = defineProps({
  id: Number,
  title: String,
  summary: String,
  date: String,
  category: String
})

const favoriteStore = useFavoriteStore()

// お気に入りボタンクリック時に呼び出される
function handleFavorite(e) {
e.preventDefault() // RouterLink の遷移を防ぐ
  favoriteStore.toggleFavorite(props.id)
}
</script>

<template>
  <RouterLink :to="{ name: 'post', params: { id } }" class="card-link">
    <div class="card">
      <span class="tag">{{ category }}</span>
      <h3>{{ title }}</h3>
      <p>{{ summary }}</p>
      <div class="card-footer">
        <span class="date">{{ date }}</span>
        <!-- お気に入りボタン:お気に入り済みは塗りつぶし、未お気に入りは枠線のみ表示 -->
        <button class="fav-btn" @click="handleFavorite">
          {{ favoriteStore.isFavorite(id) ? '&#x2665;' : '♡' }}
        </button>
      </div>
    </div>
  </RouterLink>
</template>

<style scoped>
.card-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 10px;
}
.fav-btn {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: #e74c3c;
  padding: 4px 8px;
}
.fav-btn:hover {
  transform: scale(1.2);
}
</style>

詳細ページにもお気に入りボタンを追加する

例

<!-- ファイルパス:src/views/PostView.vue(一部コード) -->
<script setup>
import { useFavoriteStore } from '../stores/useFavoriteStore.js'

const favoriteStore = useFavoriteStore()
// ... その他のコード
</script>

<template>
  <article v-if="article">
    <div class="post-header">
      <h1>{{ article.title }}</h1>
      <!-- 詳細ページのお気に入りボタン:状態はホームページと完全に同期 -->
      <button class="fav-btn" @click="favoriteStore.toggleFavorite(article.id)">
        {{ favoriteStore.isFavorite(article.id) ? '&#x2665;お気に入り済み' : '♡ お気に入り' }}
      </button>
    </div>
    <!-- ... -->
  </article>
</template>

ホームページと詳細ページのお気に入り状態は完全に同期します:ホームページで記事をお気に入りに追加すると、詳細ページで自動的に「お気に入り済み」と表示されます。

これがグローバル状態管理の価値です:データは1つだけ保存され、すべてのコンポーネントが同じソースを読み書きします。emit での受け渡しも、props の階層ごとの転送も不要です。


NavBar にお気に入り数を表示する

ナビバーの右側にお気に入り数を表示し、クリックするとお気に入り済みの記事のリストを表示できます。

例

<!-- ファイルパス:src/components/NavBar.vue -->
<script setup>
import { useFavoriteStore } from '../stores/useFavoriteStore.js'

const favoriteStore = useFavoriteStore()
</script>

<template>
  <header class="navbar">
    <a href="/" class="logo">サンプル Blog</a>
    <nav>
      <!-- お気に入り数のバッジ -->
      <span class="fav-badge" v-if="favoriteStore.favoriteCount > 0">
お気に入り {{ favoriteStore.favoriteCount }}
      </span>
    </nav>
  </header>
</template>

<style scoped>
.fav-badge {
  background: #e74c3c;
  color: #fff;
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 13px;
}
</style>

NavBar の数は最新のお気に入りリストと自動的に同期します——記事をお気に入りに追加または解除すると、ナビバーの数字はすぐに変わります。


Pinia の3大中核機能のまとめ

機能対応コードお気に入り機能での具体例
グローバル共有任意のコンポーネントで useXxxStore() が使用可能NavBar、BlogCard、PostView が同じお気に入りリストを共有する
リアクティブstate が変化すると、すべての利用側が自動的に更新お気に入りに追加すると、ホームページのボタンが赤くなり、ナビバーの数が +1、詳細ページも同期して更新される
永続化actions で localStorage に書き込みページを更新してもお気に入りリストは残る

本章のまとめ

この章では、Pinia によるグローバル状態管理の完全な使い方を学びました:defineStore によるストアの定義(state/getters/actions)、コンポーネントでの useXxxStore によるインスタンス取得、そして localStorage によるデータの永続化の実装です。

「記事のお気に入り」機能により、ホームページ、詳細ページ、ナビバー間のデータ同期が非常に自然になり、すべてのコンポーネントが自動的に一貫性を保ちます。

その他の拡張