VuexからPiniaへ移したのに、件数表示が更新されない。リロードするとお気に入りが消え、リセットの呼び出しでもエラーになる。こうした移行漏れは、commitやdispatchの名前を置き換えるだけでは見つかりません。
まず一つのVuexモジュールを一つのPinia storeへ移し、状態の読み方・更新・初期化・保存を順に確認します。ここでは、お気に入りのID一覧を例にします。Piniaの状態共有と、ブラウザーへ残す処理は別々に実装します。
Vuexの各要素を、Piniaのどこへ移すか
| Vuexで使っていたもの | Piniaのsetup store | 移行時に見るところ |
|---|---|---|
| module・namespace | 固有のIDを持つ defineStore |
別storeで同じIDを使わない。フォルダ階層をそのままネストする必要はない |
| state | ref などの状態 |
setup storeから状態をreturnする |
| getters | computed |
派生値の元になる状態を確かめる |
| mutations | 同期のaction、または状態への代入 | mutation層はなくなる。更新の規則がある処理はactionへ残す |
| actions | 関数。非同期なら async |
context.commit を外し、store内の状態・関数を使う |
dispatch('favorites/toggle', id) |
favorites.toggle(id) |
呼び出し元も移行する |
| 永続化plugin | 別途選ぶ・実装する | Piniaへ移すだけでは以前の保存内容は引き継がれない |
Vuexのmoduleを一つずつ移す途中で、VuexとPiniaを併用することはできます。ただし、同じお気に入り一覧を両方で更新すると、どちらが正しいか分からなくなります。機能単位で状態の持ち主を決め、更新する呼び出し元も一緒に切り替えます。旧APIの整理にはVuexの基本操作を参照できます。
setup storeとoption storeで、同じお気に入りを作る
setup storeではref・computed・関数を返す
app/stores/favorites.ts の例です。IDの一覧を状態、件数を派生値、追加・解除をactionにしています。Nuxt以外のVueアプリなら、たとえば src/stores/ に置きます。
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useFavoritesStore = defineStore('favorites', () => {
const ids = ref<string[]>([])
const count = computed(() => ids.value.length)
function toggle(id: string) {
ids.value = ids.value.includes(id)
? ids.value.filter(value => value !== id)
: [...ids.value, id]
}
function reset() { ids.value = [] }
return { ids, count, toggle, reset }
})
同じIDを押したら解除し、未登録なら追加するので、通常の操作では重複しません。idsはstoreの状態としてreturnします。「外から触らせたくないから」と隠すと、SSRや開発ツールが状態を扱えなくなる場合があります。
setup storeのリセットは、ここでは自分で定義した reset() を呼びます。option storeのような自動の $reset() がある前提では移行しません。また、この reset() がするのはPiniaの一覧を空にすることです。保存先まで消すかどうかは、後ほどつなぐ保存処理で決まります。
Vuexに近い形から始めたいならoption storeも使える
次は同じ操作をoption storeで書いた別例です。比較用にIDを favorites-option としています。実際の機能ではどちらかを選び、同じ一覧を二重に持たないようにします。
import { defineStore } from 'pinia'
export const useFavoritesOptionStore = defineStore('favorites-option', {
state: () => ({ ids: [] as string[] }),
getters: { count: state => state.ids.length },
actions: {
toggle(id: string) {
this.ids = this.ids.includes(id)
? this.ids.filter(value => value !== id)
: [...this.ids, id]
}
}
})
option storeには state・getters・actions があり、stateは初期値を返す関数です。上の例なら store.$reset() で初期状態へ戻せます。actionで this を使うため、矢印関数へ機械的に変えないでください。どちらの書き方も選べるので、移行と同時に全ファイルをsetup方式へ統一する必要はありません。
通常のVue 3アプリでは、ルートアプリへPiniaを登録してからstoreを使います。たとえば src/main.ts は次の形です。Nuxtの場合は後述のモジュールがこの部分を担います。
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')
分割代入と永続化を、別の問題として確認する
const { count } = favorites と書くと、数値のスナップショットを取り出してしまい、その変数は状態に追従しません。状態とgetterを取り出すときは const { ids, count } = storeToRefs(favorites) とします。actionは favorites.toggle(id) のまま呼べます。
一方、リロードで状態が消えるのは、分割代入とは別の問題です。Piniaはブラウザーの永続保存を自動で行いません。次の例では、app/lib/store.ts だけがlocalStorageを読み書きし、Pinia側はIDの一覧を持つことに集中します。
export const KEYS = { favorites: 'pinia-demo:favorites:v1' } as const
export const EVENTS = { favoritesChange: 'pinia-demo:favorites-change' } as const
const validIds = (value: unknown): value is string[] =>
Array.isArray(value) && value.length <= 100 &&
value.every(id => typeof id === 'string' && /^[a-z0-9-]{1,64}$/.test(id))
export function getFavorites(): { ids: string[]; error: boolean } {
try {
const raw = window.localStorage.getItem(KEYS.favorites)
if (raw === null) return { ids: [], error: false }
const value: unknown = JSON.parse(raw)
if (!validIds(value)) return { ids: [], error: true }
return { ids: [...new Set(value)], error: false }
} catch { return { ids: [], error: true } }
}
export function setFavorites(ids: string[]): boolean {
let saved = false
try {
if (validIds(ids)) {
window.localStorage.setItem(KEYS.favorites, JSON.stringify([...new Set(ids)]))
saved = true
}
} catch { /* Keep the current Pinia state usable when storage is unavailable. */ }
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(EVENTS.favoritesChange, { detail: { saved } }))
}
return saved
}
読み込みではJSONの構文だけでなく、配列か、IDが許可した文字か、件数が多すぎないかを確認します。この例のIDは小文字英数字とハイフンで最大64文字、保存数は100件までです。自分のID規則とデータ量に合わせて変更してください。書き込みが失敗しても現在の選択はPiniaに残し、戻り値で「保存できなかった」と画面へ伝えます。
| 残したい情報 | 保存の考え方 |
|---|---|
| 小さなお気に入りID一覧・表示設定 | このようなlocalStorageの窓口を自作し、形式と上限を決める |
| 複数storeの定型的な永続化 | 対応版・SSR対応・保存対象の選別・データ移行方法を確認してpluginを選ぶ |
| 別端末とも共有する情報 | ブラウザー内だけでは共有できない。サーバー保存と同期の設計が必要 |
| 認証tokenや秘密情報 | このお気に入り保存例へ混ぜない。認証方式に応じて別に設計する |
既存のvuex-persistedstateの設定を消す前に、保存キーと実際のJSON形式も確認します。上の例は新しいキーを使うため、古い保存内容を自動では読みません。移行するなら、必要なフィールドだけを変換・検証し、新形式を保存して読み直せたことを確かめてから旧形式の扱いを決めます。認証情報や別機能の状態まで丸ごとコピーしないようにします。
Nuxtでは、サーバーの初期化とブラウザー保存の復元を分ける
以下はNuxt 4のファイル配置です。例の検証にはNuxt 4.5.2、Vue 3.5.42、Pinia 4.0.3、@pinia/nuxt 1.0.2を使っています。既存アプリへ入れる場合は、利用中のバージョンとの依存関係を確認し、対応する pinia と @pinia/nuxt をインストールします。
nuxt.config.ts でモジュールを登録します。
export default defineNuxtConfig({
compatibilityDate: '2026-09-12',
modules: ['@pinia/nuxt'],
devtools: { enabled: false },
telemetry: false
})
サーバーで用意できる一覧は、storeのactionで取得します。ここでは学習メモの公開一覧で、利用者固有の情報は含みません。app/stores/catalog.ts は次の内容です。
import { defineStore } from 'pinia'
export const useCatalogStore = defineStore('catalog', {
state: () => ({ items: [] as { id: string; title: string }[] }),
actions: {
async load() { this.items = await $fetch('/api/catalog') }
}
})
動かすための server/api/catalog.get.ts も用意します。
export default defineEventHandler(() => [
{ id: 'vue-notes', title: 'Vueの学習メモ' },
{ id: 'css-notes', title: 'CSSの学習メモ' }
])
ルートの app/app.vue では、SSRで使う一覧を callOnce で初期化し、お気に入りの復元は onMounted へ分けます。表示用CSSを除いた実装です。
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useFavoritesStore } from './stores/favorites'
import { useCatalogStore } from './stores/catalog'
import { getFavorites, setFavorites } from './lib/store'
const favorites = useFavoritesStore()
const { ids, count } = storeToRefs(favorites)
const catalog = useCatalogStore()
await callOnce('catalog-init', () => catalog.load())
const ready = ref(false)
const storageMessage = ref('')
let unsubscribe: (() => void) | undefined
onMounted(() => {
const saved = getFavorites()
favorites.ids = saved.ids
if (saved.error) storageMessage.value = '保存内容を読み込めなかったため、空の状態で開始しました。'
unsubscribe = favorites.$subscribe((_mutation, state) => {
storageMessage.value = setFavorites(state.ids)
? '' : '画面の選択は更新しましたが、ブラウザーに保存できませんでした。'
}, { flush: 'sync' })
ready.value = true
})
onBeforeUnmount(() => unsubscribe?.())
</script>
<template>
<main>
<h1>お気に入りの状態と保存を分ける</h1>
<p>選択中:<strong data-count>{{ count }}</strong>件</p>
<FavoriteSummary />
<ul><li v-for="item in catalog.items" :key="item.id">
<span>{{ item.title }}</span>
<button :disabled="!ready" :aria-pressed="ids.includes(item.id)"
:data-id="item.id" @click="favorites.toggle(item.id)">
{{ ids.includes(item.id) ? '解除' : 'お気に入り' }}
</button>
</li></ul>
<button :disabled="!ready" data-reset @click="favorites.reset()">すべて解除</button>
<p role="status">{{ storageMessage }}</p>
</main>
</template>
callOnce は、この初期化でSSR側が取得した一覧をクライアント側で重ねて取り直さないために使っています。ユーザーを切り替えた後の再取得まで自動で面倒を見る機能ではないので、認証や動的なデータへ応用する場合は、再取得する条件も設計します。
localStorageはサーバーから読めません。SSRでは空のお気に入りを出し、クライアントの最初の描画も同じ状態にしてから、マウント後に復元します。復元前に操作できないよう、ボタンは ready がtrueになるまで無効です。画面を開いた直後に件数が0から保存数へ変わることはあります。最初のHTMLから個人の選択を表示したいなら、サーバーでも読める保存方式を検討します。
復元してから購読を始める順番にも意味があります。先に空の状態を保存する処理を走らせると、読み込み前の保存内容を上書きしかねません。この例は復元後の変更を $subscribe で保存します。全解除も変更として保存するので、リロード後に古い一覧へ戻りません。購読は破棄時に解除します。
別コンポーネントの app/components/FavoriteSummary.vue も同じstoreを使えます。
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useFavoritesStore } from '../stores/favorites'
const { count } = storeToRefs(useFavoritesStore())
</script>
<template><p>別コンポーネントの件数:<strong data-summary>{{ count }}</strong></p></template>
storeを使う呼び出しは、コンポーネントのsetupやNuxtが提供する文脈で行います。SSRでモジュールのトップレベルに一つのstoreインスタンスを作って使い回すと、リクエスト間で状態を共有する原因になり得ます。初期値の不一致が残る場合は、Nuxtのhydration不一致を切り分ける例も確認してください。
移行完了は、追加できたことだけで判断しない
お気に入りの移行なら、別コンポーネントにも件数が反映されるか、追加・解除の後にリロードしても意図した状態になるかを確認します。setupとoptionの両方を試すなら、リセットの呼び出しが違うこともテストへ残します。
保存データが壊れたときは空で開始し、そのことを表示する。書き込めないときは画面の選択を維持し、保存成功と表示しない。この二つも、移行前の正常データだけでは見つからない確認点です。この最小例では別タブとの同期は行いません。changeイベントを出すことと、他タブの状態を読み直して同期することは別の処理です。
最後に、移行した機能への古い commit・dispatch・mapGetters の参照と、旧保存pluginの対象を点検します。状態を持つ場所、読み書きする場所、初期化する時点がそれぞれ一つに決まっていれば、残りのmoduleも同じ観点で移せます。
確認日:2026年9月12日。例のコードは公開一覧とブラウザー内のお気に入りを使った検証用です。