Pinia で状態が更新されない?参照の再利用・シャロー比較・getter 依存の落とし穴

Pinia で状態管理をしているのに、なぜか画面が更新されない...そんな経験はありませんか。状態を変更したつもりなのに、コンポーネントが再レンダリングされず、ユーザーには変更が反映されないという問題は、Vue.js 開発者なら一度は遭遇する課題です。
この記事では、Pinia における状態更新の検知に関する 3 つの主要な落とし穴について詳しく解説いたします。参照の再利用、シャロー比較の限界、getter の依存関係の誤解といった、見落としがちながらも重要な問題について、具体的なコード例とともに解決策をご紹介します。
背景
Pinia の状態管理の基本仕組み
Pinia は Vue.js の公式状態管理ライブラリとして、アプリケーション全体で共有される状態を効率的に管理します。Pinia ストアは state
、getters
、actions
の 3 つの要素で構成されており、それぞれが異なる役割を担っています。
javascript// 基本的な Pinia ストアの構造
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
users: [],
currentUser: null,
settings: {},
}),
getters: {
activeUsers: (state) =>
state.users.filter((user) => user.active),
userCount: (state) => state.users.length,
},
actions: {
addUser(user) {
this.users.push(user);
},
updateUser(id, updates) {
const user = this.users.find((u) => u.id === id);
if (user) {
Object.assign(user, updates);
}
},
},
});
このストア構造により、コンポーネント間で状態を共有し、一元的に管理できます。しかし、状態の変更が適切に検知されない場合があります。
Vue の リアクティブシステムとの連携
Vue のリアクティブシステムは、状態の変更を自動的に検知し、関連するコンポーネントを再レンダリングする仕組みです。Pinia はこのリアクティブシステムを活用して状態管理を実現しています。
以下の図は、Vue のリアクティブシステムと Pinia の関係を示しています。
mermaidflowchart TD
component[Vue コンポーネント] -->|状態参照| pinia[Pinia ストア]
pinia -->|リアクティブな状態| reactive[Vue リアクティブシステム]
reactive -->|変更検知| watcher[ウォッチャー]
watcher -->|再レンダリング| component
action[アクション実行] -->|状態変更| pinia
pinia -.->|変更通知| reactive
Vue のリアクティブシステムは、オブジェクトのプロパティアクセスを監視し、変更があった際に依存関係を追跡します。
よくある状態更新の期待値
開発者が状態を更新する際、以下のような動作を期待することが一般的です。
操作 | 期待される動作 | 実際の結果 |
---|---|---|
配列に要素を追加 | 即座に画面更新 | 時々更新されない |
オブジェクトのプロパティ変更 | コンポーネント再レンダリング | 変更が反映されない場合がある |
getter の依存状態変更 | computed が再計算される | 古い値のまま |
これらの期待と実際の動作の違いが、状態更新に関する問題の根本原因となっています。
課題
パターン 1:参照の再利用による更新検知失敗
参照の再利用問題は、同じオブジェクトや配列の参照を使い回すことで発生します。Vue のリアクティブシステムは参照の変更を監視しているため、参照が変わらない限り変更を検知できません。
以下のコードは典型的な問題例です。
javascript// 問題のあるコード例
export const useTaskStore = defineStore('task', {
state: () => ({
tasks: [],
}),
actions: {
// ❌ 参照が変わらないため、変更が検知されない
updateTaskStatus(taskId, status) {
const task = this.tasks.find((t) => t.id === taskId);
if (task) {
task.status = status; // 同じオブジェクトを変更
}
},
// ❌ 配列の参照が変わらない
sortTasks() {
this.tasks.sort((a, b) => a.priority - b.priority);
},
},
});
この問題が発生する原理について詳しく見てみましょう。
mermaidsequenceDiagram
participant Component as コンポーネント
participant Store as Pinia ストア
participant Reactive as リアクティブシステム
Component->>Store: アクション実行
Store->>Store: 既存オブジェクトを直接変更
Note over Store: 参照は変わらない
Store-->>Reactive: 参照チェック
Reactive-->>Component: 変更なしと判定
Note over Component: 再レンダリングされない
参照が変わらないため、リアクティブシステムは変更を検知できず、コンポーネントの再レンダリングが発生しません。
パターン 2:シャロー比較による見落とし
シャロー比較の問題は、ネストしたオブジェクトや配列の深い部分の変更が検知されない現象です。Vue のリアクティブシステムは基本的にシャロー(浅い)な変更検知を行うため、深い階層の変更を見落とすことがあります。
javascript// シャロー比較で見落とされる例
export const useUserStore = defineStore('user', {
state: () => ({
userProfile: {
personal: {
name: '',
age: 0,
address: {
street: '',
city: '',
country: '',
},
},
preferences: {
theme: 'light',
notifications: [],
},
},
}),
actions: {
// ❌ 深い階層の変更が検知されない場合がある
updateAddress(newAddress) {
this.userProfile.personal.address = newAddress;
},
// ❌ 配列内オブジェクトの変更
updateNotificationSetting(index, setting) {
this.userProfile.preferences.notifications[index] =
setting;
},
},
});
以下の図は、シャロー比較と深い変更検知の違いを示しています。
mermaidgraph TD
root[ルートオブジェクト] --> personal[personal]
root --> preferences[preferences]
personal --> name[name]
personal --> address[address]
address --> street[street]
address --> city[city]
style root fill:#e1f5fe
style personal fill:#fff3e0
style address fill:#ffebee
style street fill:#ffcdd2
classDef shallow fill:#4caf50,color:#fff
classDef deep fill:#f44336,color:#fff
class root,personal shallow
class address,street deep
図解の要点:
- 緑色の部分:シャロー比較で検知可能
- 赤色の部分:深い変更で見落とされがち
- 階層が深くなるほど検知が困難
パターン 3:getter の依存関係の誤解
getter の依存関係問題は、getter が期待通りに再計算されない現象です。Vue の computed プロパティと同様に、getter も依存する状態が変更された時のみ再計算されますが、依存関係の追跡が正しく行われない場合があります。
javascript// getter の依存関係で問題が発生する例
export const useProductStore = defineStore('product', {
state: () => ({
products: [],
filters: {
category: '',
minPrice: 0,
maxPrice: 1000,
},
sortOrder: 'name',
}),
getters: {
// ❌ 依存関係が正しく追跡されない場合がある
filteredProducts: (state) => {
let result = state.products;
// 条件分岐により依存関係が変わる
if (state.filters.category) {
result = result.filter(
(p) => p.category === state.filters.category
);
}
// 外部関数を使用すると依存関係が追跡されない
return result.filter((product) =>
isInPriceRange(product, state.filters)
);
},
// ❌ 非リアクティブなデータへの依存
sortedProducts: (state) => {
const sortFunc = getSortFunction(state.sortOrder); // 外部関数
return [...state.products].sort(sortFunc);
},
},
});
// 外部関数(リアクティブではない)
function isInPriceRange(product, filters) {
return (
product.price >= filters.minPrice &&
product.price <= filters.maxPrice
);
}
依存関係の追跡問題を図で示すと以下のようになります。
mermaidflowchart LR
state[State] --> getter[Getter]
getter --> computed[Computed結果]
external[外部関数] -.-> getter
condition[条件分岐] -.-> getter
style external fill:#ffcdd2
style condition fill:#fff3e0
style state fill:#e8f5e8
style computed fill:#e3f2fd
classDef tracked fill:#4caf50,color:#fff
classDef untracked fill:#f44336,color:#fff
class state,computed tracked
class external,condition untracked
依存関係の追跡における注意点:
- 実線:正常に追跡される依存関係
- 点線:追跡されない可能性がある依存関係
解決策
参照の再利用を避ける方法
参照の再利用問題を解決するには、新しい参照を作成することが重要です。JavaScript のスプレッド演算子や専用のメソッドを活用することで、確実に参照を更新できます。
javascript// 参照を確実に更新する方法
export const useTaskStore = defineStore('task', {
state: () => ({
tasks: [],
}),
actions: {
// ✅ 新しいオブジェクトを作成して参照を更新
updateTaskStatus(taskId, status) {
this.tasks = this.tasks.map((task) =>
task.id === taskId
? { ...task, status } // 新しいオブジェクトを作成
: task
);
},
// ✅ 新しい配列を作成
sortTasks() {
this.tasks = [...this.tasks].sort(
(a, b) => a.priority - b.priority
);
},
// ✅ 配列の要素を安全に追加
addTask(task) {
this.tasks = [...this.tasks, task];
},
// ✅ 要素を安全に削除
removeTask(taskId) {
this.tasks = this.tasks.filter(
(task) => task.id !== taskId
);
},
},
});
より複雑なオブジェクトの場合は、以下のようなヘルパー関数を使用することも有効です。
javascript// ヘルパー関数を使用した安全な更新
import { cloneDeep } from 'lodash-es';
export const useUserStore = defineStore('user', {
state: () => ({
users: [],
}),
actions: {
// ✅ ディープクローンを使用した安全な更新
updateUserDeep(userId, updates) {
this.users = this.users.map((user) =>
user.id === userId
? { ...cloneDeep(user), ...updates }
: user
);
},
// ✅ 構造化代入を活用した更新
updateUserPartial(userId, path, value) {
this.users = this.users.map((user) => {
if (user.id !== userId) return user;
const updatedUser = { ...user };
const keys = path.split('.');
let current = updatedUser;
for (let i = 0; i < keys.length - 1; i++) {
current[keys[i]] = { ...current[keys[i]] };
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
return updatedUser;
});
},
},
});
ディープな状態変更の適切な手法
ディープな状態変更については、Vue 3 の reactive
や専用のライブラリを活用することで、より確実に変更を検知できます。
javascript// Vue 3 の reactive を活用した深い状態管理
import { reactive } from 'vue';
import { defineStore } from 'pinia';
export const useProfileStore = defineStore(
'profile',
() => {
// ✅ reactive を明示的に使用
const userProfile = reactive({
personal: {
name: '',
age: 0,
address: {
street: '',
city: '',
country: '',
},
},
preferences: {
theme: 'light',
notifications: [],
},
});
// ✅ 深い更新を安全に行う関数
function updateAddress(newAddress) {
Object.assign(
userProfile.personal.address,
newAddress
);
}
// ✅ ネストしたオブジェクトの部分更新
function updatePreference(key, value) {
userProfile.preferences[key] = value;
}
// ✅ 配列の安全な操作
function addNotification(notification) {
userProfile.preferences.notifications.push(
notification
);
}
function removeNotification(index) {
userProfile.preferences.notifications.splice(
index,
1
);
}
return {
userProfile,
updateAddress,
updatePreference,
addNotification,
removeNotification,
};
}
);
Immer ライブラリを使用することで、より直感的にイミュータブルな更新を行うこともできます。
javascript// Immer を使用したイミュータブルな更新
import { produce } from 'immer';
export const useDataStore = defineStore('data', {
state: () => ({
complexData: {
users: [],
settings: {},
cache: {},
},
}),
actions: {
// ✅ Immer を使用した安全な深い更新
updateComplexData(updater) {
this.complexData = produce(
this.complexData,
(draft) => {
updater(draft);
}
);
},
// ✅ 具体的な使用例
addUserWithSettings(user, settings) {
this.updateComplexData((draft) => {
draft.users.push(user);
draft.settings[user.id] = settings;
draft.cache[user.id] = { lastAccess: new Date() };
});
},
},
});
getter 依存の正しい理解と実装
getter の依存関係を正しく追跡するには、リアクティブなデータのみに依存し、外部関数の使用を最小限に抑えることが重要です。
javascript// getter の正しい実装方法
export const useProductStore = defineStore('product', {
state: () => ({
products: [],
filters: {
category: '',
minPrice: 0,
maxPrice: 1000,
},
sortOrder: 'name',
}),
getters: {
// ✅ 依存関係が明確なgetter
filteredProducts: (state) => {
return state.products.filter((product) => {
// すべての条件をgetter内で定義
const categoryMatch =
!state.filters.category ||
product.category === state.filters.category;
const priceMatch =
product.price >= state.filters.minPrice &&
product.price <= state.filters.maxPrice;
return categoryMatch && priceMatch;
});
},
// ✅ 他のgetterに依存する場合
sortedFilteredProducts: (state) => {
const filtered = this.filteredProducts; // 他のgetterを参照
switch (state.sortOrder) {
case 'name':
return [...filtered].sort((a, b) =>
a.name.localeCompare(b.name)
);
case 'price':
return [...filtered].sort(
(a, b) => a.price - b.price
);
case 'date':
return [...filtered].sort(
(a, b) =>
new Date(b.createdAt) - new Date(a.createdAt)
);
default:
return filtered;
}
},
// ✅ computed と組み合わせる場合
productStats: (state) => {
const filtered = this.filteredProducts;
return {
total: filtered.length,
averagePrice:
filtered.reduce((sum, p) => sum + p.price, 0) /
filtered.length || 0,
categories: [
...new Set(filtered.map((p) => p.category)),
],
};
},
},
actions: {
// ✅ フィルター更新のアクション
updateFilter(filterType, value) {
this.filters[filterType] = value;
},
updateSortOrder(order) {
this.sortOrder = order;
},
},
});
composition API スタイルでより柔軟な getter 管理を行う方法もあります。
javascript// Composition API スタイルでのgetter実装
import { computed } from 'vue';
import { defineStore } from 'pinia';
export const useAdvancedProductStore = defineStore(
'advancedProduct',
() => {
const products = ref([]);
const filters = reactive({
category: '',
minPrice: 0,
maxPrice: 1000,
searchText: '',
});
const sortConfig = reactive({
field: 'name',
direction: 'asc',
});
// ✅ 複数の computed を組み合わせた段階的なフィルタリング
const categoryFiltered = computed(() => {
if (!filters.category) return products.value;
return products.value.filter(
(p) => p.category === filters.category
);
});
const priceFiltered = computed(() => {
return categoryFiltered.value.filter(
(p) =>
p.price >= filters.minPrice &&
p.price <= filters.maxPrice
);
});
const textFiltered = computed(() => {
if (!filters.searchText) return priceFiltered.value;
const searchLower = filters.searchText.toLowerCase();
return priceFiltered.value.filter(
(p) =>
p.name.toLowerCase().includes(searchLower) ||
p.description.toLowerCase().includes(searchLower)
);
});
const sortedProducts = computed(() => {
const sorted = [...textFiltered.value];
const { field, direction } = sortConfig;
sorted.sort((a, b) => {
let comparison = 0;
if (a[field] < b[field]) comparison = -1;
if (a[field] > b[field]) comparison = 1;
return direction === 'desc'
? -comparison
: comparison;
});
return sorted;
});
return {
products,
filters,
sortConfig,
filteredProducts: sortedProducts,
// 各段階のフィルター結果も公開
categoryFiltered,
priceFiltered,
textFiltered,
};
}
);
具体例
ケース 1:配列操作での失敗例と修正例
ショッピングカート機能を例に、配列操作での問題と解決方法を見てみましょう。
javascript// ❌ 問題のあるショッピングカート実装
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
total: 0,
}),
actions: {
// ❌ 直接配列を変更(参照が変わらない)
addItemWrong(product) {
const existingItem = this.items.find(
(item) => item.id === product.id
);
if (existingItem) {
existingItem.quantity += 1; // 既存オブジェクトを直接変更
} else {
this.items.push({ ...product, quantity: 1 }); // push は参照を変更しない
}
this.calculateTotal();
},
// ❌ 配列のソートで参照が変わらない
sortItemsWrong() {
this.items.sort((a, b) =>
a.name.localeCompare(b.name)
);
},
},
});
このコードの問題点を図で示すと以下のようになります。
mermaidflowchart TD
action[アクション実行] --> find[既存アイテム検索]
find --> exists{アイテム存在?}
exists -->|Yes| modify[既存オブジェクト変更]
exists -->|No| push[配列にpush]
modify --> same_ref[同じ参照]
push --> same_array[同じ配列参照]
same_ref --> no_update[更新検知されない]
same_array --> no_update
style modify fill:#ffcdd2
style push fill:#ffcdd2
style no_update fill:#f44336,color:#fff
以下が修正版のコードです。
javascript// ✅ 正しいショッピングカート実装
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
total: 0,
}),
getters: {
itemCount: (state) =>
state.items.reduce(
(sum, item) => sum + item.quantity,
0
),
cartTotal: (state) => {
return state.items.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
},
},
actions: {
// ✅ 新しい配列を作成して参照を更新
addItem(product) {
const existingIndex = this.items.findIndex(
(item) => item.id === product.id
);
if (existingIndex >= 0) {
// 既存アイテムの場合:新しい配列とオブジェクトを作成
this.items = this.items.map((item, index) =>
index === existingIndex
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
// 新しいアイテムの場合:新しい配列を作成
this.items = [
...this.items,
{ ...product, quantity: 1 },
];
}
},
// ✅ 数量を安全に更新
updateQuantity(productId, quantity) {
if (quantity <= 0) {
this.removeItem(productId);
return;
}
this.items = this.items.map((item) =>
item.id === productId ? { ...item, quantity } : item
);
},
// ✅ アイテムを安全に削除
removeItem(productId) {
this.items = this.items.filter(
(item) => item.id !== productId
);
},
// ✅ 新しい配列でソート
sortItems(sortBy = 'name') {
this.items = [...this.items].sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'price':
return a.price - b.price;
case 'quantity':
return b.quantity - a.quantity;
default:
return 0;
}
});
},
// ✅ カート全体をクリア
clearCart() {
this.items = [];
},
},
});
使用例と効果の確認:
javascript// コンポーネントでの使用例
import { useCartStore } from '@/stores/cart';
export default {
setup() {
const cartStore = useCartStore();
// ✅ リアクティブに更新される
const addToCart = (product) => {
cartStore.addItem(product);
// 即座にUIが更新される
};
const updateItemQuantity = (productId, quantity) => {
cartStore.updateQuantity(productId, quantity);
// 数量変更が即座に反映される
};
return {
cartStore,
addToCart,
updateItemQuantity,
};
},
};
ケース 2:オブジェクトの深い更新での問題と解決
ユーザープロフィール管理システムを例に、深いオブジェクト更新の問題と解決策を説明します。
javascript// ❌ 深いオブジェクト更新での問題例
export const useProfileStore = defineStore('profile', {
state: () => ({
user: {
id: '',
personal: {
firstName: '',
lastName: '',
email: '',
phone: '',
address: {
street: '',
city: '',
state: '',
zipCode: '',
country: '',
},
},
preferences: {
theme: 'light',
language: 'en',
notifications: {
email: true,
push: false,
sms: false,
},
},
settings: {
privacy: {
profileVisible: true,
showEmail: false,
},
security: {
twoFactorEnabled: false,
loginAlerts: true,
},
},
},
}),
actions: {
// ❌ 深いプロパティの直接変更
updateAddressWrong(addressData) {
// 既存オブジェクトを直接変更
Object.assign(
this.user.personal.address,
addressData
);
},
// ❌ ネストしたオブジェクトの部分更新
updateNotificationSettingWrong(type, value) {
this.user.preferences.notifications[type] = value;
},
},
});
この実装の問題を可視化すると以下のようになります。
mermaidgraph TD
user[user オブジェクト] --> personal[personal]
user --> preferences[preferences]
user --> settings[settings]
personal --> address[address]
preferences --> notifications[notifications]
settings --> privacy[privacy]
settings --> security[security]
address --> street[street]
address --> city[city]
style user fill:#e8f5e8
style personal fill:#fff3e0
style address fill:#ffebee
style street fill:#ffcdd2
style city fill:#ffcdd2
classDef tracked fill:#4caf50,color:#fff
classDef shallow fill:#ff9800,color:#fff
classDef deep fill:#f44336,color:#fff
class user tracked
class personal,preferences,settings shallow
class address,notifications,privacy,security deep
図解の要点:
- 緑:確実に追跡される階層
- オレンジ:部分的に追跡される階層
- 赤:追跡が困難な深い階層
正しい実装方法は以下の通りです。
javascript// ✅ 深いオブジェクト更新の正しい実装
export const useProfileStore = defineStore('profile', {
state: () => ({
user: {
id: '',
personal: {
firstName: '',
lastName: '',
email: '',
phone: '',
address: {
street: '',
city: '',
state: '',
zipCode: '',
country: '',
},
},
preferences: {
theme: 'light',
language: 'en',
notifications: {
email: true,
push: false,
sms: false,
},
},
settings: {
privacy: {
profileVisible: true,
showEmail: false,
},
security: {
twoFactorEnabled: false,
loginAlerts: true,
},
},
},
}),
actions: {
// ✅ 新しいオブジェクトを作成して更新
updatePersonalInfo(personalData) {
this.user = {
...this.user,
personal: {
...this.user.personal,
...personalData,
},
};
},
// ✅ 住所の安全な更新
updateAddress(addressData) {
this.user = {
...this.user,
personal: {
...this.user.personal,
address: {
...this.user.personal.address,
...addressData,
},
},
};
},
// ✅ 通知設定の安全な更新
updateNotificationSetting(type, value) {
this.user = {
...this.user,
preferences: {
...this.user.preferences,
notifications: {
...this.user.preferences.notifications,
[type]: value,
},
},
};
},
// ✅ プライバシー設定の更新
updatePrivacySetting(setting, value) {
this.user = {
...this.user,
settings: {
...this.user.settings,
privacy: {
...this.user.settings.privacy,
[setting]: value,
},
},
};
},
// ✅ ヘルパー関数を使用した汎用的な更新
updateNestedProperty(path, value) {
const keys = path.split('.');
let result = { ...this.user };
let current = result;
// 最後のキー以外を辿って新しいオブジェクトを作成
for (let i = 0; i < keys.length - 1; i++) {
current[keys[i]] = { ...current[keys[i]] };
current = current[keys[i]];
}
// 最後のキーに値を設定
current[keys[keys.length - 1]] = value;
this.user = result;
},
},
getters: {
fullName: (state) => {
const { firstName, lastName } = state.user.personal;
return `${firstName} ${lastName}`.trim();
},
formattedAddress: (state) => {
const addr = state.user.personal.address;
return `${addr.street}, ${addr.city}, ${addr.state} ${addr.zipCode}, ${addr.country}`;
},
notificationCount: (state) => {
const notifications =
state.user.preferences.notifications;
return Object.values(notifications).filter(Boolean)
.length;
},
},
});
より高度な解決策として、Immer を使用したアプローチも紹介します。
javascript// ✅ Immer を使用したより直感的な深い更新
import { produce } from 'immer';
export const useAdvancedProfileStore = defineStore(
'advancedProfile',
{
state: () => ({
user: {
// 同じ構造...
},
}),
actions: {
// ✅ Immer を使用した直感的な更新
updateWithImmer(updater) {
this.user = produce(this.user, (draft) => {
updater(draft);
});
},
// ✅ 具体的な使用例
updateAddress(addressData) {
this.updateWithImmer((draft) => {
Object.assign(
draft.personal.address,
addressData
);
});
},
updateMultipleSettings(updates) {
this.updateWithImmer((draft) => {
updates.forEach(({ path, value }) => {
const keys = path.split('.');
let current = draft;
for (let i = 0; i < keys.length - 1; i++) {
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
});
});
},
},
}
);
ケース 3:computed との組み合わせでの注意点
複雑なダッシュボードアプリケーションを例に、computed プロパティと getter の相互作用における問題と解決策を説明します。
javascript// ❌ computed との相互作用で問題が発生する例
export const useDashboardStore = defineStore('dashboard', {
state: () => ({
rawData: [],
filters: {
dateRange: { start: null, end: null },
category: '',
status: '',
},
sortConfig: { field: 'date', direction: 'desc' },
pagination: { page: 1, limit: 20 },
}),
getters: {
// ❌ 外部関数に依存(依存関係が追跡されない)
filteredData: (state) => {
return filterDataByComplexLogic(
state.rawData,
state.filters
);
},
// ❌ 条件分岐により依存関係が不安定
processedData: (state) => {
let data = this.filteredData;
// 条件によって使用するプロパティが変わる
if (state.sortConfig.field === 'custom') {
data = sortByCustomLogic(
data,
getCustomSortOrder()
);
} else {
data = [...data].sort((a, b) => {
// 通常のソート
});
}
return data;
},
},
});
// 外部関数(リアクティブではない)
function filterDataByComplexLogic(data, filters) {
// 複雑なフィルタリングロジック
}
function getCustomSortOrder() {
// グローバル状態から取得(リアクティブではない)
return (
localStorage.getItem('customSortOrder') || 'default'
);
}
この問題を解決した正しい実装は以下の通りです。
javascript// ✅ computed との正しい相互作用
export const useDashboardStore = defineStore(
'dashboard',
() => {
// State
const rawData = ref([]);
const filters = reactive({
dateRange: { start: null, end: null },
category: '',
status: '',
searchText: '',
});
const sortConfig = reactive({
field: 'date',
direction: 'desc',
});
const pagination = reactive({
page: 1,
limit: 20,
});
// ✅ 段階的なcomputed(依存関係が明確)
const dateFilteredData = computed(() => {
if (
!filters.dateRange.start ||
!filters.dateRange.end
) {
return rawData.value;
}
const start = new Date(filters.dateRange.start);
const end = new Date(filters.dateRange.end);
return rawData.value.filter((item) => {
const itemDate = new Date(item.date);
return itemDate >= start && itemDate <= end;
});
});
const categoryFilteredData = computed(() => {
if (!filters.category) return dateFilteredData.value;
return dateFilteredData.value.filter(
(item) => item.category === filters.category
);
});
const statusFilteredData = computed(() => {
if (!filters.status)
return categoryFilteredData.value;
return categoryFilteredData.value.filter(
(item) => item.status === filters.status
);
});
const searchFilteredData = computed(() => {
if (!filters.searchText)
return statusFilteredData.value;
const searchLower = filters.searchText.toLowerCase();
return statusFilteredData.value.filter(
(item) =>
item.title.toLowerCase().includes(searchLower) ||
item.description
.toLowerCase()
.includes(searchLower)
);
});
// ✅ ソート処理(すべてをcomputed内で実行)
const sortedData = computed(() => {
const data = [...searchFilteredData.value];
const { field, direction } = sortConfig;
data.sort((a, b) => {
let comparison = 0;
// すべてのソートロジックをcomputed内で定義
switch (field) {
case 'date':
comparison =
new Date(a.date) - new Date(b.date);
break;
case 'title':
comparison = a.title.localeCompare(b.title);
break;
case 'category':
comparison = a.category.localeCompare(
b.category
);
break;
case 'status':
const statusOrder = {
active: 1,
pending: 2,
inactive: 3,
};
comparison =
(statusOrder[a.status] || 999) -
(statusOrder[b.status] || 999);
break;
case 'priority':
comparison =
(a.priority || 0) - (b.priority || 0);
break;
default:
comparison = 0;
}
return direction === 'desc'
? -comparison
: comparison;
});
return data;
});
// ✅ ページネーション
const paginatedData = computed(() => {
const start =
(pagination.page - 1) * pagination.limit;
const end = start + pagination.limit;
return sortedData.value.slice(start, end);
});
// ✅ 統計情報(他のcomputedに依存)
const stats = computed(() => {
const filtered = searchFilteredData.value;
const total = rawData.value.length;
return {
total,
filtered: filtered.length,
categories: [
...new Set(filtered.map((item) => item.category)),
],
statuses: [
...new Set(filtered.map((item) => item.status)),
],
pages: Math.ceil(
filtered.length / pagination.limit
),
};
});
// Actions
function updateFilter(key, value) {
filters[key] = value;
pagination.page = 1; // フィルター変更時はページをリセット
}
function updateSort(field, direction = null) {
sortConfig.field = field;
sortConfig.direction =
direction ||
(sortConfig.field === field &&
sortConfig.direction === 'asc'
? 'desc'
: 'asc');
}
function changePage(page) {
if (page >= 1 && page <= stats.value.pages) {
pagination.page = page;
}
}
function resetFilters() {
Object.assign(filters, {
dateRange: { start: null, end: null },
category: '',
status: '',
searchText: '',
});
pagination.page = 1;
}
return {
// State
rawData,
filters,
sortConfig,
pagination,
// Computed
filteredData: searchFilteredData,
sortedData,
paginatedData,
stats,
// Intermediate computed (for debugging)
dateFilteredData,
categoryFilteredData,
statusFilteredData,
// Actions
updateFilter,
updateSort,
changePage,
resetFilters,
};
}
);
コンポーネントでの使用例:
javascript// コンポーネントでの効果的な使用
<template>
<div class="dashboard">
<!-- フィルター -->
<div class="filters">
<input
v-model="store.filters.searchText"
placeholder="検索..."
/>
<select v-model="store.filters.category">
<option value="">すべてのカテゴリ</option>
<option
v-for="category in store.stats.categories"
:key="category"
:value="category"
>
{{ category }}
</option>
</select>
</div>
<!-- データ表示 -->
<div class="data-table">
<div
v-for="item in store.paginatedData"
:key="item.id"
class="data-item"
>
{{ item.title }}
</div>
</div>
<!-- 統計 -->
<div class="stats">
{{ store.stats.filtered }} / {{ store.stats.total }} 件表示
</div>
</div>
</template>
<script setup>
import { useDashboardStore } from '@/stores/dashboard'
const store = useDashboardStore()
// ✅ すべてのcomputed が正しく依存関係を追跡
// フィルター変更 → 自動的にデータ更新 → UI更新
</script>
この実装により、以下の利点が得られます。
- 明確な依存関係: 各 computed の依存関係が明確
- 段階的な処理: 複雑なデータ変換を段階的に実行
- デバッグの容易さ: 中間結果も確認可能
- パフォーマンス最適化: 必要な部分のみ再計算
まとめ
Pinia で状態が更新されない問題は、主に参照の再利用、シャロー比較の限界、getter の依存関係の誤解という 3 つの原因に起因します。これらの問題を解決するには、以下のポイントを押さえることが重要です。
参照の更新を確実に行う:オブジェクトや配列を変更する際は、常に新しい参照を作成しましょう。スプレッド演算子や map
、filter
などのイミュータブルな操作を活用することで、リアクティブシステムが確実に変更を検知できます。
深い状態変更の適切な管理:ネストしたオブジェクトを扱う場合は、Vue 3 の reactive
を明示的に使用するか、Immer などのライブラリを活用して安全な更新を行いましょう。各階層で新しいオブジェクトを作成することが、確実な変更検知の鍵となります。
getter の依存関係を明確にする:getter 内では外部関数の使用を避け、すべてのロジックを getter 内で完結させることで、依存関係の追跡を確実に行えます。Composition API スタイルを使用することで、より柔軟で追跡しやすい状態管理が可能になります。
これらの解決策を適用することで、Pinia における状態更新の問題を根本的に解決し、予測可能で保守しやすいアプリケーションを構築できるでしょう。状態管理は Vue.js アプリケーションの中核となる部分ですので、正しい理解と実装により、開発効率と品質の向上を実現してください。
関連リンク
- article
Pinia で状態が更新されない?参照の再利用・シャロー比較・getter 依存の落とし穴
- article
Pinia アーキテクチャ超図解:リアクティビティとストアの舞台裏を一枚で理解
- article
Pinia × TypeScript:型安全なストア設計入門
- article
Pinia の基本 API 解説:defineStore・state・getters・actions
- article
Vuex から Pinia への移行ガイド:違いとメリットを比較
- article
Pinia 入門:Vue 3 で最速の状態管理を始めよう
- article
【比較検証】Convex vs Firebase vs Supabase:リアルタイム性・整合性・学習コストの最適解
- article
【徹底比較】Preact vs React 2025:バンドル・FPS・メモリ・DX を総合評価
- article
GPT-5-Codex vs Claude Code / Cursor 徹底比較:得意領域・精度・開発速度の違いを検証
- article
Astro × Cloudflare Workers/Pages:エッジ配信で超高速なサイトを構築
- article
【2025 年版】Playwright vs Cypress vs Selenium 徹底比較:速度・安定性・学習コストの最適解
- article
Apollo を最短導入:Vite/Next.js/Remix での初期配線テンプレ集
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来