T-CREATOR

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

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

Pinia で状態管理をしているのに、なぜか画面が更新されない...そんな経験はありませんか。状態を変更したつもりなのに、コンポーネントが再レンダリングされず、ユーザーには変更が反映されないという問題は、Vue.js 開発者なら一度は遭遇する課題です。

この記事では、Pinia における状態更新の検知に関する 3 つの主要な落とし穴について詳しく解説いたします。参照の再利用、シャロー比較の限界、getter の依存関係の誤解といった、見落としがちながらも重要な問題について、具体的なコード例とともに解決策をご紹介します。

背景

Pinia の状態管理の基本仕組み

Pinia は Vue.js の公式状態管理ライブラリとして、アプリケーション全体で共有される状態を効率的に管理します。Pinia ストアは stategettersactions の 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 つの原因に起因します。これらの問題を解決するには、以下のポイントを押さえることが重要です。

参照の更新を確実に行う:オブジェクトや配列を変更する際は、常に新しい参照を作成しましょう。スプレッド演算子や mapfilter などのイミュータブルな操作を活用することで、リアクティブシステムが確実に変更を検知できます。

深い状態変更の適切な管理:ネストしたオブジェクトを扱う場合は、Vue 3 の reactive を明示的に使用するか、Immer などのライブラリを活用して安全な更新を行いましょう。各階層で新しいオブジェクトを作成することが、確実な変更検知の鍵となります。

getter の依存関係を明確にする:getter 内では外部関数の使用を避け、すべてのロジックを getter 内で完結させることで、依存関係の追跡を確実に行えます。Composition API スタイルを使用することで、より柔軟で追跡しやすい状態管理が可能になります。

これらの解決策を適用することで、Pinia における状態更新の問題を根本的に解決し、予測可能で保守しやすいアプリケーションを構築できるでしょう。状態管理は Vue.js アプリケーションの中核となる部分ですので、正しい理解と実装により、開発効率と品質の向上を実現してください。

関連リンク