Vueではコンポーネント間でデータの受け渡しを行いたい場合にProps, Emitを利用することができます。Vue3ではProps, Emitの変わりにProvide/Injectも利用することができます。Props, Emitでは親子関係を持つコンポーネント間でしかデータの受け渡しを行うことができないため階層が深くなると処理が複雑になります。そのような問題を解決するためにはすべてのコンポーネントでデータを共有するための仕組み(State Management)が必要になります。

Vue.jsではGlobal State ManagementライブラリとしてVuexが有名ですがVuexの後継として新たにPiniaというStoreライブラリが登場しVue.jsで新しくプロジェクトを作成する場合はPiniaを利用することが推奨されています。ネット上にはVuexに関する記事が豊富に存在すると思いますがこれからVue.jsを学習するのであればVuexは気にせずPiniaを利用してください。

本文書はComposition APIを利用してPiniaの基本機能とPiniaを使った少し実践的なカートの作り方を通してPiniaの理解を深めることを目的としています。PiniaはOptions APIでも利用することができます。

Props, Emit, Provide/Injectについては下記の文書で公開済みなので参考にしてみてください。

Piniaとは

PiniaはVue用のState Managementライブラリで複数のコンポーネントでデータを共有するために利用することができます。アプリケーションの中にStoreという場所を準備しその中にコンポーネント間で共有の必要があるデータを保存していきます。またデータを保存するだけではなくデータを更新する機能も備えています。

Piniaによるデータ管理のイメージ
Piniaによるデータ管理のイメージ

Vueプロジェクトの作成

Piniaを利用するためにVueのプロジェクトの作成を行います。本文書ではViteでプロジェクトを作成後にnpmコマンドでpiniaのインストールを行います。

viteではなくnpm create vue@latestコマンドを利用するとプロジェクトの作成と同時にpiniaをインストールすることもできます。

Viteによるインストール

npm create viteコマンドを実行してVueのプロジェクトの作成を行います。


 % npm create vite@latest vue3-pinia -- --template vue

プロジェクト作成後にプロジェクトフォルダに移動してnpm installコマンドを実行してpiniaのインストールを行います。


 % npm install pinia

piniaのインストール後package.jsonを確認するとインストールされているpiniaのバージョンを確認することができます。動作確認時の最新版である2.0.9がインストールされていることがわかります。


{
  "name": "vue3-pinia",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "pinia": "^2.1.7",
    "vue": "^3.4.21"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.4",
    "vite": "^5.2.0"
  }
}

Piniaの設定

PiniaをVueで利用するためには初期設定が必要となります。srcフォルダのmain.jsファイルを使ってPluginとしてPiniaの追加を行います。


import { createApp } from 'vue';
import './style.css';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
app.use(createPinia());
app.mount('#app');

Storeの作成

初期設定が完了したらアプリケーション内でデータの共有を行うためStoreの作成を行います。Storeは1つではなく複数作成することができ、目的や機能ごとにファイルを分けて管理することができます。

srcフォルダの下にstoresという名前のフォルダを作成します。storesフォルダの下に複数のStoreを作成することができるのでフォルダ名はstoreではなく複数形のstoresという名前にしていますが名前は任意なので好きな名前をつけてください。作成したstoresの下にcounter.jsファイルを作成してください。最初はカウンター機能を利用してpiniaの基本的な動作確認を行います。

Storeを定義するためにpiniaからdefineStoreをimportします。複数のStoreを定義することができるのでStoreを識別するためのidを設定します。idには”counter”という名前をつけています。stateに共有を行いたいcount変数を定義し初期値を設定しています。defineStore関数の戻り値をuseStoreCounterという名前をつけてexportしています。このexportした関数をコンポーネントでimportして利用します。useStoreCounterのように名前の先頭にuseを利用するのは慣例ですが、好きな名前をつけることができます。


import { defineStore } from 'pinia';

export const useStoreCounter = defineStore('counter', {
  state: () => ({
    count: 1,
  }),
});

Storeのデータへのアクセス確認

Storeでcountを定義したのでコンポーネントからアクセスできるか確認してみましょう。アクセスはApp.vueファイルから行います。

Appコンポーネントではcounter.jsファイルでexportしたuseStoreCounterをimportします。importしたuseStoreCounter関数を実行した戻り値の中にStoreで定義したcountの値が保存されています。そのためcounter.countでアクセスすることができます。


<script setup>
import { useStoreCounter } from './stores/counter';
const counter = useStoreCounter();
</script>

<template>
  <h1>Pinia入門</h1>
  <p>Count:{{ counter.count }}</p>
</template>

開発サーバを起動していない場合は起動を行います。


% npm run dev

> vue3-pinia@0.0.0 dev
> vite


  VITE v5.2.11  ready in 316 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

ブラウザで確認するとStoreで設定したcountの初期値の1を確認することができます。Storeで定義したデータへのアクセス方法がわかりました。

Storeで定義したcountの値を表示
Storeで定義したcountの値を表示

DevtoolsによるStoreの確認

ブラウザの拡張機能のVue Devtoolsを利用することでPiniaのStoreの状態を確認することができます。ブラウザにDevtoolsのインストールが行われていない場合はhttps://devtools.vuejs.org/guide/installation.htmlを参考に行なってください。

ブラウザにDevToolsがインストールされているとVue.jsの状態を確認することができます。デベロッパーツールを開くとコンソールタブのようにVueのタブが表示されるのでVueタブを選択します。左側のサイドメニューを確認するとPiniaのアイコンを確認することができます。

Piniaを選択するとPiniaの状態を確認することができ、counterという名前を確認することができます。defineStoreでStoreを定義する際につけたidが識別子として利用されています。/stores/count.jsファイルのidを変更すると表示されているcounterの名前も変更した名前に変更されます。

vue DevtoolsでPiniaを確認
vue DevtoolsでPiniaを確認

counter.jsファイルのstateには複数の変数やオブジェクトを設定することができるので設定を行ってみます。


import { defineStore } from 'pinia';

export const useStoreCounter = defineStore('counter', {
  state: () => ({
    count: 1,
    count2: 2,
    user: {
      name: 'John Doe',
    },
  }),
});

Devtoolsにも設定した変数が反映されることが確認できます。

Storeに追加した変数を確認
Storeに追加した変数を確認

他のコンポーネントからのアクセス

Piniaを利用する目的はすべてのコンポーネント上からStoreに定義したデータにアクセスができることなので別のコンポーネントからアクセスできるのか確認するために新たにコンポーネントを作成します。

componentsフォルダにHelloPinia.vueファイルを作成して以下を記述します。


<script setup>
import { useStoreCounter } from '../stores/counter';
const counter = useStoreCounter();
</script>

<template>
  <h2>Hello Pinia</h2>
  <p>カウント:{{ counter.count }}</p>
</template>

作成したHelloPinia.vueファイルをApp.vueファイルでimportしてtemplateタグに追加します。


<script setup>
import { useStoreCounter } from './stores/counter';
import HelloPinia from './components/HelloPinia.vue';
const counter = useStoreCounter();
</script>

<template>
  <h1>Pinia入門</h1>
  <p>Count:{{ counter.count }}</p>
  <HelloPinia />
</template>

追加後ブラウザを確認するとHelloPiniaコンポーネントに記述したcountの値が表示されます。複数のコンポーネントでもStoreに保存されたデータにアクセスできることがわかりました。

複数のコンポーネントからのStoreのデータへのアクセス
複数のコンポーネントからのStoreのデータへのアクセス

Actionsの設定

Storeに保存したデータへのアクセス方法を理解することができたので次はデータを更新する方法を確認します。Composition APIではscriptタグの中に関数を追加してreactiveなデータの更新を行いますがPiniaではdefineStoreのプロパティactionsに関数を追加してStoreに定義したデータの更新を行います。

counter.jsファイルのdefineStoreの中にactionsを追加しincrement関数を追加します。


import { defineStore } from 'pinia';

export const useStoreCounter = defineStore('counter', {
  state: () => ({
    count: 1,
    count2: 2,
    user: {
      name: 'John Doe',
    },
  }),
  actions: {
    increment() {
      this.count++;
    },
  },
});

これでコンポーネントからincrement関数を利用することが可能になります。

App.vueファイルにclickイベントを持つボタンを追加しボタンをクリックするとStoreのActionsで定義したincrement関数が実行できるように設定します。increment関数へのアクセス方法はcountへのアクセス方法と同じでStoreのcounterに.(ドット)をつけて関数名を設定します。


<script setup>
import { useStoreCounter } from './stores/counter';
import HelloPinia from './components/HelloPinia.vue';
const counter = useStoreCounter();
</script>

<template>
  <h1>Pinia入門</h1>
  <p>Count:{{ counter.count }}</p>
  <div>
    <button @click="counter.increment">Up</button>
  </div>
  <HelloPinia />
</template>

devtoolsを開きながら追加したUpボタンをクリックします。Devtoolsのcountの値はボタンを押すごとに増えていくことがわかります。実行したAppコンポーネントのcountの値だけではなくHelloPiniaコンポーネントのcountの値も一緒に増えていくことが確認できます。あるコンポーネントで更新を行うとStoreで共有されたデータはreactiveなのですべてのコンポーネントに更新が反映されることがわかりました。

Storeのcount変数の値を更新する
Storeのcount変数の値を更新する

分割代入の設定

Storeに保存されている変数と関数の一部のみを利用したい場合は分割代入を利用することができます。templateタグではcounter.が必要でなくなるためコードがスッキリします。


<script setup>
import { useStoreCounter } from './stores/counter';
import HelloPinia from './components/HelloPinia.vue';
const { count, increment } = useStoreCounter();
</script>

<template>
  <h1>Pinia入門</h1>
  <p>Count:{{ count }}</p>
  <div>
    <button @click="increment">Up</button>
  </div>
  <HelloPinia />
</template>

表示した初期値の1は表示されますがボタンをクリックするとincrementが実行できていることはDevtoolsの値とHelloPiniaコンポーネントのcountをみることで確認できますがAppコンポーネントのcountの数が増えることはありません。

分割代入を行うとcountの値が更新されない
分割代入を行うとcountの値が更新されない

Appコンポーネントのcountの値が更新されないのはreactiveな性質が分割代入によって失われたためです。分割代入を行ってもreactiveな性質を保持するためにはstoreToRefs関数を利用する必要があります。


import { useStoreCounter } from './stores/counter';
import HelloPinia from './components/HelloPinia.vue';
import { storeToRefs } from 'pinia';
const counter = useStoreCounter();
const { count } = storeToRefs(counter);
const { increment } = counter;

再度確認するとAppコンポーネントのcountもボタンと同時に更新されることが確認できます。

Storeのcount変数の値を更新する
Storeのcount変数の値を更新する

さらにDevtoolsではTimelineを見ることでstateの履歴の情報なども確認することができます。このようにDevtoolsを利用することでPiniaの状態が確認できるため動作確認に活用することができます。

DevtoolsによるTimelineの確認
DevtoolsによるTimelineの確認

Gettersの設定

GettersはStoreで定義したデータに対してComputedプロパティの設定を行いたい時に利用することができます。actionsと同様にdefineStoreのgettersプロパティに設定を行います。gettersにdoubleCount関数を設定すると引数にdefineStoreで定義しているstateを受け取ることができます。下記では受け取ったstateのcountを利用してcountの値を2倍にしています。Computedプロパティと同様に必ずreturnで処理結果を戻す必要があります。


import { defineStore } from 'pinia';

export const useStoreCounter = defineStore('counter', {
  state: () => ({
    count: 1,
    count2: 2,
    user: {
      name: 'John Doe',
    },
  }),
  getters: {
    doubleCount: (state) => state.count * 2,
  },
  actions: {
    increment() {
      this.count++;
    },
  },
});

分割代入を利用する場合はgettersの関数もstoreToRefsを利用する必要があります。


<script setup>
import { useStoreCounter } from './stores/counter';
import HelloPinia from './components/HelloPinia.vue';
import { storeToRefs } from 'pinia';
const counter = useStoreCounter();
const { count, doubleCount } = storeToRefs(counter);
const { increment } = counter;
</script>

<template>
  <h1>Pinia入門</h1>
  <p>Count:{{ count }}</p>
  <p>DoubleCount:{{ doubleCount }}</p>
  <div>
    <button @click="increment">Up</button>
  </div>
  <HelloPinia />
</template>

gettersで定義したdoubleCountはComputedプロパティと同じなのでcountの値が更新されると一緒に更新が行われます。ボタンを3度クリックするとcountが4になり、doubleCountは2倍なので8となります。

gettersを利用した場合の更新の確認
gettersを利用した場合の更新の確認

defineStoreの記述方法

defineStoreの中でstate, actions, gettersのプロパティを利用して設定を行いましたが関数を利用して下記のように記述することもできます。どちらを利用するかも好みの問題ないので好きな設定方法で記述してください。


import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useStoreCounter = defineStore('counter', () => {
  const count = ref(1);

  const increment = () => {
    count.value++;
  };

  const doubleCount = computed(() => count.value * 2);

  return { count, increment, doubleCount };
});

$reset, $patch

Storeのactionsに追加した関数以外にもStoreの持つ$reset, $patchメソッドを利用してstateの値を更新することができます。

$resetメソッドの利用方法

$resetメソッドを利用するとstateの値が初期値にリセットされます。HelloPiniaコンポーネントにボタンを追加して動作確認を行います。


<script setup>
import { useStoreCounter } from '../stores/counter';
const counter = useStoreCounter();
</script>

<template>
  <h2>Hello Pinia</h2>
  <p>カウント:{{ counter.count }}</p>
  <button @click="counter.$reset">Reset</button>
</template>

Upボタンをクリックしてcountの数を増やします。

resetボタンを追加
resetボタンを追加

resetボタンをクリックしてください。countの値はdefineStoreで設定した初期値に戻ります。

$resetメソッドにより初期値にリセット
$resetメソッドにより初期値にリセット

$patchメソッドの利用方法

$resetメソッドを実行すると初期値に値がリセットされましたが$patchメソッドを利用するとstateの値を更新することができます。actionsを利用する必要はありません。

HelloPiniaコンポーネントにpatch関数を追加して$patchメソッドでstateの中のcountとuserの値を更新します。


<script setup>
import { useStoreCounter } from '../stores/counter';
const counter = useStoreCounter();

const patch = () => {
  counter.$patch({
    count: 100,
    user: {
      name: 'Jane Doe',
    },
  });
};
</script>

<template>
  <h2>Hello Pinia</h2>
  <p>カウント:{{ counter.count }}</p>
  <button @click="patch">Patch</button>
</template>

追加したPatchボタンをクリックするとstateの中の値が更新されるので更新された値ばブラウザ上に表示されます。

$patchメソッドによる値の更新
$patchメソッドによる値の更新

$patchメソッドの引数では関数を利用することで引数にstateが渡されるため渡されたstateで更新を行うこともできます。


const patch = () => {
  counter.$patch((state) => {
    state.count = 10;
    state.user.name = 'Kevin Doe';
  });
};

Patchボタンをクリックすると先ほどと同じように更新が行われます。

StateのReplace

$reset, $patchとは異なりメソッドではありませんが$stateに値を設定することで指定したプロパティの値にすべて取り替えることができます。


<script setup>
import { useStoreCounter } from '../stores/counter';
const counter = useStoreCounter();

const replace = () => {
  counter.$state = {
    count: 100,
    count2: 200,
    user: {
      name: 'Jane Doe',
    },
  };
};
</script>

<template>
  <h2>Hello Pinia</h2>
  <p>カウント:{{ counter.count }}</p>
  <button @click="replace">Replace</button>
</template>

追加した Replaceボタンを押すとcount以外のプロパティについても$stateで指定した値になっていることがDevtoolsの値からも確認することができます。

$stateで指定した値に更新
$stateで指定した値に更新

$subscribeメソッド

$subscribeメソッドを利用することでPiniaのストアのstateの変更を監視することができます。stateが変更される度に実行されます。


<script setup>
import { useStoreCounter } from './stores/counter';
import HelloPinia from './components/HelloPinia.vue';
import { storeToRefs } from 'pinia';
const counter = useStoreCounter();
const { count, doubleCount } = storeToRefs(counter);
const { increment } = counter;
counter.$subscribe((mutation, state) => {
  console.log('mutation:', mutation);
  console.log('state:', state);
});
</script>

<template>
  <h1>Pinia入門</h1>
  <p>Count:{{ count }}</p>
  <p>DoubleCount:{{ doubleCount }}</p>
  <div>
    <button @click="increment">Up</button>
  </div>
  <HelloPinia />
</template>

ボタンをクリックする度にブラウザのデベロッパーツールのコンソールにmutation, stateの情報が表示されます。

$subscribeでPiniaのストアの変更を監視
$subscribeでPiniaのストアの変更を監視

JSON.stringfyを利用して引数のstateをJSON形式で表示させるとcounterのstate全体の状態が含まれていることが確認できます。


counter.$subscribe((mutation, state) => {
  console.log('mutation:', mutation);
  console.log('state:', JSON.stringify(state));
});
JSON.stringfyでstateの中身をJSONで表示
JSON.stringfyでstateの中身をJSONで表示

$onActionメソッド

Piniaのストアのstateの状態の変化を$subscribeで監視することができました。actionsを監視したい場合には$onActionを利用することができます。


<script setup>
import { useStoreCounter } from './stores/counter';
import HelloPinia from './components/HelloPinia.vue';
import { storeToRefs } from 'pinia';
const counter = useStoreCounter();
const { count, doubleCount } = storeToRefs(counter);
const { increment } = counter;
// counter.$subscribe((mutation, state) => {
//   console.log('mutation:', mutation);
//   console.log('state:', JSON.stringify(state));
// });
const unsubscribe = counter.$onAction(
  ({
    name, // name of the action
    store, // store instance, same as `someStore`
    args, // array of parameters passed to the action
    after, // hook after the action returns or resolves
    onError, // hook if the action throws or rejects
  }) => {
    // a shared variable for this specific action call
    const startTime = Date.now();
    // this will trigger before an action on `store` is executed
    console.log(`Start "${name}" with params [${args.join(', ')}].`);

    // this will trigger if the action succeeds and after it has fully run.
    // it waits for any returned promised
    after((result) => {
      console.log(
        `Finished "${name}" after ${
          Date.now() - startTime
        }ms.\nResult: ${result}.`
      );
    });

    // this will trigger if the action throws or returns a promise that rejects
    onError((error) => {
      console.warn(
        `Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
      );
    });
  }
);
</script>

<template>
  <h1>Pinia入門</h1>
  <p>Count:{{ count }}</p>
  <p>DoubleCount:{{ doubleCount }}</p>
  <div>
    <button @click="increment">Up</button>
  </div>
  <HelloPinia />
</template>

ボタンをクリックする度にコンソールに下記のメッセージが表示されます。

$onActionの動作確認
$onActionの動作確認

Piniaを利用した例

Piniaの基本的な利用方法の説明は完了しました。Piniaってたったこれだけなのかと印象を持っている人もいるかと思います。ここからはPiniaの理解をさらに深めるためにカウンター機能よりも少し複雑なショッピングカートを作成してみましょう。

作成するコードは画面に一覧商品された商品をクリックするとカートに追加されるといったシンプルな動作ですがカート内に含まれる商品の合計金額や在庫のチェックなどPiniaを理解する上で必須な機能であるState, Actions, Gettersを利用して実装しています。

商品情報の準備

通常であればバックエンドサーバから商品情報を取得することになりますが今回は商品一覧をファイルから取得する形としています。srcフォルダにapiフォルダを作成しshop.jsファイルを作成してください。

shop.jsファイルには3つの商品を登録しています。商品情報としてid、商品名(title)、金額(price)、在庫数(inventory)を持ちます。getProducts関数が定義されており引数にはcallback関数が入ります。cbはcallbackの略です。


const _products = [
  { id: 1, title: 'AirPods(第3世代)', price: 23800, inventory: 2 },
  { id: 2, title: '11インチiPad Pro', price: 94800, inventory: 5 },
  { id: 3, title: '14インチMacBook Pro', price: 239800, inventory: 3 },
];

export default {
  getProducts(cb) {
    setTimeout(() => cb(_products), 100);
  },
};

商品一覧の表示

shop.jsに保存されている商品情報を取得し、一覧表示するためにcomponentsフォルダにProductList.vueファイルを作成します。

商品一覧を保存するためにref関数を利用してproductsを定義します。コンポーネントのマウント時に商品情報を取得するためにimportしたshop.jsのgetProductsを利用してproductsに商品情報を保存します。保存した商品一覧はv-forディレクティブを利用してtemplateタグで展開しています。


<script setup>
import { onMounted, ref } from 'vue';
import shop from '../api/shop.js';
const products = ref([]);
onMounted(() => {
  shop.getProducts((data) => (products.value = data));
});
</script>

<template>
  <ul>
    <li v-for="product in products" v-bind:key="product.id">
      {{ product.title }} - ¥{{ product.price.toLocaleString() }}
    </li>
  </ul>
</template>

shop.getProductsの引数のcallback関数に慣れていない人もいるかもしれせんが下記のように処理されproducts.valueにshop.jsで定義した商品情報の_productsが入るだけです。


setTimeout(() => (_products) => (product.value = _products, 100);

作成したProductListコンポーネントをAppコンポーネントでimportしてtemplateタグに追加します。


<script setup>
import ProductList from './components/ProductList.vue';
</script>

<template>
  <div id="app">
    <h1>Pinia入門(カート)</h1>
    <hr />
    <h2>商品一覧</h2>
    <ProductList />
  </div>
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

main.jsファイルの中身も確認しておきます。


import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const app = createApp(App);
app.use(createPinia());
app.mount('#app');

ブラウザで確認するとshop.jsファイルで定義した商品一覧が表示されます。

商品一覧を表示
商品一覧を表示

商品一覧を表示することができましたがここまでの設定はProductListコンポーネントのローカルにproductsを定義して商品情報をProductListコンポーネント内で取得して表示させただけです。ここからPiniaを利用してStoreに商品情報を保存してStoreに保存したデータを取得できるように変更します。

Storeの作成(products)

storesフォルダに商品に関する情報を保存するproducts.jsファイルを追加し、defineStoreでproductsを定義します。

stateで配列を持つproductsを定義し、actionsにgetProcucts関数を追加しgetProducts関数を実行することで商品一覧を取得できるようにします。


import { defineStore } from 'pinia';
import shop from '../api/shop.js';

export const useStoreProducts = defineStore('products', {
  state: () => ({
    products: [],
  }),
  actions: {
    getProducts() {
      shop.getProducts((products) => (this.products = products));
    },
  },
});

productsのStoreを作成したらProductListコンポーネントの内容を変更します。先ほどはProcutListコンポーネントから商品情報を取得していましたがPiiniaのStoreを利用してデータの取得を行います。


<script setup>
import { onMounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useStoreProducts } from '../stores/products';

const { products } = storeToRefs(useStoreProducts());
const { getProducts } = useStoreProducts();

onMounted(() => {
  getProducts();
});
</script>

<template>
  <ul>
    <li v-for="product in products" v-bind:key="product.id">
      {{ product.title }} - ¥{{ product.price.toLocaleString() }}
    </li>
  </ul>
</template>

表示される内容は同じですがdevToolsを見るとデータはproductsのStoreに保存されていることがわかります。ProductListコンポーネントではStoreのgetProcuts関数を実行してshop.jsから商品一覧を取得しStore内のproductsに保存し、Storeに保存されたproductsから商品情報を取得して表示しています。

PiniaのStoreのproductsに保存された情報を表示
PiniaのStoreのproductsに保存された情報を表示

Storeの作成(cart)

カートに入れる商品情報を管理するためにstoresフォルダにcart.jsファイルを作成します。cart.jsではカートに入れた商品情報を保持するために配列のitemsを定義します。カートに商品を追加できるようにaddCart関数をactionsに追加します。addCart関数の引数に商品情報が入っていればitemsの配列に商品情報を追加することができます。


import { defineStore } from 'pinia';

export const useStoreCart = defineStore('cart', {
  state: () => ({
    items: [],
  }),
  actions: {
    addCart(product) {
      this.items.push(product);
    },
  },
});

カートへの追加機能

cartのStoreが作成できたので商品一覧から商品をカートに追加するためのボタンをProductListコンポーネントに追加します。useStoreCartをimportしてaddCart関数を利用できるようにする必要があります。


<script setup>
import { onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useStoreProducts } from '../stores/products';
import { useStoreCart } from '../stores/cart';

const { products } = storeToRefs(useStoreProducts());
const { getProducts } = useStoreProducts();
const { addCart } = useStoreCart();

onMounted(() => {
  getProducts();
});
</script>

<template>
  <ul>
    <li v-for="product in products" v-bind:key="product.id">
      {{ product.title }} - ¥{{ product.price.toLocaleString() }}
      <button @click="addCart(product)">カートへ</button>
    </li>
  </ul>
</template>

”カートへ”ボタンが正常に動作しているかブラウザ上から確認します。カートの情報を表示するコンポーネントは作成していないのでDevtoolsで確認します。

”カートへ”ボタンをクリックするとcart Storeのitemsに商品情報が追加されることが確認できます。

Storeのcartに追加された商品を確認
Storeのcartに追加された商品を確認

カートに商品を追加することができるようになったのでカートの中に入った商品を表示させるShoppingCartコンポーネントを作成します。

Cartコンポーネントの作成

componentsフォルダにShoppingCart.vueファイルを作成します。importしたuseStoreCartからitemsを利用してv-forディレクティブで展開しています。itemsの配列が空の場合には”カートに商品は入っていません。”というメッセージを表示できるようにしています。空かどうかはlengthプロパティを利用しています。


<script setup>
import { storeToRefs } from 'pinia';
import { useStoreCart } from '../stores/cart';

const { items } = storeToRefs(useStoreCart());
</script>
<template>
  <h2>カートの中身</h2>
  <p v-show="!items.length"><i>カートに商品は入っていません。</i></p>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.title }} - ¥{{ item.price.toLocaleString() }}
    </li>
  </ul>
</template>

作成したShoppingCartコンポーネントはApp.vueでimportして表示できるように設定をします。


<script setup>
import ProductList from './components/ProductList.vue';
import ShoppingCart from './components/ShoppingCart.vue';
</script>

<template>
  <div id="app">
    <h1>Pinia入門(カート)</h1>
    <hr />
    <h2>商品一覧</h2>
    <ProductList />
    <hr />
    <ShoppingCart />
  </div>
</template>

ブラウザ上には商品一覧とカートが表示されるようになりました。itemsに何も入っていない場合には”カートには商品は入っていません。”が表示されます。

カートをブラウザ上に表示
カートをブラウザ上に表示

”カートへ”ボタンを何度かクリックしてカートに商品を入れてください。addCartでは何も制御を行っていないためボタンを押した回数分同じ商品であるかに関わらずカートに登録されることになります。

カートへの商品の追加
カートへの商品の追加

カートの制御

同じ商品が複数行表示されないようにカートへの商品追加の制御を行います。カートに入れた数量をカウントできるようにカートへの追加が行われた時にquantityプロパティを追加します。処理はcart.jsファイルのaddCart関数で行います。

find関数を利用してカートに同じ商品が入っているかチェックを行い、入っていない場合はquantityを1にしてカートに追加し、すでに商品が入っている場合にはquantityを増やすように設定を行います。


import { defineStore } from 'pinia';

export const useStoreCart = defineStore('cart', {
  state: () => ({
    items: [],
  }),
  actions: {
    addCart(product) {
      const item = this.items.find((item) => item.id === product.id);
      if (item) {
        item.quantity++;
      } else {
        this.items.push({ ...product, quantity: 1 });
      }
    },
  },
});

ShoppingCartコンポーネントでは数量が表示できるように更新します({{ item.quantity}}を追加)。


<script setup>
import { storeToRefs } from 'pinia';
import { useStoreCart } from '../stores/cart';

const { items } = storeToRefs(useStoreCart());
</script>
<template>
  <h2>カートの中身</h2>
  <p v-show="!items.length"><i>カートに商品は入っていません。</i></p>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.title }} - ¥{{ item.price.toLocaleString() }} x
      {{ item.quantity }}
    </li>
  </ul>
</template>

同じ商品の”カートへ”ボタンを複数回押すと数量が増えるか確認します。同じ商品をカートに入れると数量が2, 3と増えていけば正常に動作していることになります。

カートに入れた商品の数量の表示確認
カートに入れた商品の数量の表示確認

在庫数の制御

商品情報にはinventoryとして在庫数の情報を持っています。商品をカートに入れて在庫数が0になった場合にカートへ追加ができなくなるように機能の追加を行います。在庫数が0になった場合は”カートへ”ボタンが押せなくなるようにします。

productsのStoreに在庫数を減らすための関数decrementInventoryを追加します。追加したdecrementInventory関数では引数から商品のidを受け取り商品一覧からそのidを持つ商品を見つけ在庫数を1減らすという処理を行なっています。


import { defineStore } from 'pinia';
import shop from '../api/shop.js';

export const useStoreProducts = defineStore('products', {
  state: () => ({
    products: [],
  }),
  actions: {
    getProducts() {
      shop.getProducts((products) => (this.products = products));
    },
    decrementInventory(productId) {
      const product = this.products.find((product) => product.id === productId);
      product.inventory--;
    },
  },
});

カートに商品が入るごとに在庫数を減らす処理の実行はcart.jsファイルのaddCart関数内で行います。在庫数を減らすためにはproducts StoreのdecrementInventory関数を利用する必要になります。cart.jsファイルからuseStoreProductsをimportしてdecrementInventory関数を利用します。このようにStoreは別のStoreでも利用することができます。


import { defineStore } from 'pinia';
import { useStoreProducts } from './products';

export const useStoreCart = defineStore('cart', {
  state: () => ({
    items: [],
  }),
  actions: {
    addCart(product) {
      const { decrementInventory } = useStoreProducts();
      const item = this.items.find((item) => item.id === product.id);
      if (item) {
        item.quantity++;
      } else {
        this.items.push({ ...product, quantity: 1 });
      }
      decrementInventory(product.id);
    },
  },
});

カートへ商品を追加すると在庫数が減るのか確認を行うためDevtoolsを利用します。AirPods(第3世代) は在庫数が2つありますがカートに2つ入れるとInventoryの値が0になっていることが確認できます。

カートに追加するとInvenrotyの数が減る
カートに追加するとInvenrotyの数が減る

さらに”カートへ”ボタンをクリックしていくとinventoryの値がマイナスになっていきます。0になったらボタンが押せないようにbutton要素のdisabled属性とv-bindを利用して制御を行います。設定はProductListコンポーネントで行います。


<template>
  <ul>
    <li v-for="product in products" v-bind:key="product.id">
      {{ product.title }} - ¥{{ product.price.toLocaleString() }}
      <button @click="addCart(product)" :disabled="!product.inventory">
        カートへ
      </button>
    </li>
  </ul>
</template>

設定完了後、商品在庫の数分をカートに入れるとdisabledによりボタンがクリックできなくなります。

在庫数以上の商品をカートに入れることはできない
在庫数以上の商品をカートに入れることはできない

gettersの利用

ここまでの動作確認でactionsは利用してきましたがgettersは利用していませんでした。最後にカートに入っている合計金額を計算するためにgettersを利用します。

gettersを追加しtotal関数を追加します。total関数ではreducer関数を利用してカートに保存されている商品を1つずつ順番に取り出して金額を足し合わせています。


import { defineStore } from 'pinia';
import { useStoreProducts } from './products';

export const useStoreCart = defineStore('cart', {
  state: () => ({
    items: [],
  }),
  getters: {
    total: (state) => {
      return state.items.reduce((total, product) => {
        return total + product.price * product.quantity;
      }, 0);
    },
  },
  actions: {
    addCart(product) {
      const { decrementInventory } = useStoreProducts();
      const item = this.items.find((item) => item.id === product.id);
      if (item) {
        item.quantity++;
      } else {
        this.items.push({ ...product, quantity: 1 });
      }
      decrementInventory(product.id);
    },
  },
});

gettersのtotalをShoppingCartコンポーネントで利用します。


<script setup>
import { storeToRefs } from 'pinia';
import { useStoreCart } from '../stores/cart';

const { items, total } = storeToRefs(useStoreCart());
</script>
<template>
  <h2>カートの中身</h2>
  <p v-show="!items.length"><i>カートに商品は入っていません。</i></p>
  <ul>
    <li v-for="item in items" :key="item.id">
      {{ item.title }} - ¥{{ item.price.toLocaleString() }} x
      {{ item.quantity }}
    </li>
  </ul>
  <h3>合計金額:¥{{ total.toLocaleString() }}</h3>
</template>

複数の商品をカートに入れていくと合計金額も計算が行われます。

合計金額の表示
合計金額の表示

カートの情報を保持

カートに商品を追加してもブラウザのリロードを行うと追加した商品情報は消えてなくなります。ブラウザのlocalStorageを利用することでカートの内容をブラウザのリロード後も保持することができます。

VueUseのライブラリの中のuseStorageを利用することで簡単に実現することができます。

VueUseのライブラリのインストールを行います。


% npm install @vueuse/core

cart.jsファイルでvueuse/coreからuseStorageをimportしてstateのitemsの初期値として利用するだけです。


import { defineStore } from 'pinia';
import { useStoreProducts } from './products';
import { useStorage } from '@vueuse/core';

export const useStoreCart = defineStore('cart', {
  state: () => ({
    // items: [],
    items: useStorage('cart-items', []),
  }),
  getters: {
    total: (state) => {
      return state.items.reduce((total, product) => {
        return total + product.price * product.quantity;
      }, 0);
    },
  },
  actions: {
    addCart(product) {
      const { decrementInventory } = useStoreProducts();
      const item = this.items.find((item) => item.id === product.id);
      if (item) {
        item.quantity++;
      } else {
        this.items.push({ ...product, quantity: 1 });
      }
      decrementInventory(product.id);
    },
  },
});

localStorageの内容はブラウザのデベロッパーツールのApplicationのLocal storageから確認することができます。ブラウザをリロードしてもカートの中身を保持できるようになりました。

Local storageの確認
Local storageの確認

カートの中身の数量の変更などカートの機能としては不十分ですがPiniaの基本機能を使うことができたのでカートを使った動作確認は完了です。機能を追加したい人はぜひチャレンジしてください。

ここまで読み通してくれた人であればPiniaの理解も最初よりはかなり深まっていると思います。Vueで状態管理を利用する機会があればぜひPiniaを利用してみてください。