Vueではコンポーネント間でデータを受け渡ししたい場合にProps, Emitを利用することができます。Props, Emitでは親子関係を持つコンポーネント間でしかデータの受け渡しを行うことができないため階層が深くなると処理が複雑になります。そのような問題を解決するためにはすべてのコンポーネントでデータを共有するための仕組み(State Management)が必要になります。Vue.jsではState ManagementライブラリとしてVuexが有名ですが新たにPiniaというStoreライブラリが登場しVue.jsで新しくプロジェクトを作成する場合はPiniaを利用することが推奨されています。本文書はPiniaの基本機能とPiniaを使った少し実践的なカートの作り方を通してPiniaの理解を深めることを目的としています。

Piniaとは

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

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

Vueプロジェクトの作成

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

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

Viteによるインストール

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


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

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


 % npm install pinia

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


{
  "name": "vue3-pinia",
  "version": "0.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "pinia": "^2.0.9",
    "vue": "^3.2.25"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^2.0.0",
    "vite": "^2.7.2"
  }
}

Piniaの設定

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


import { createApp } from 'vue'
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して利用します。


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>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

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


 % npm run dev

ブラウザで確認すると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タブを選択します。Vueの画面が表示され下記のようにComponentsと表示されている文字をクリックするとPiniaの選択が表示されます。

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

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

Pinia rootの確認
Pinia rootの確認

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タグに追加します。


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で処理結果を戻す必要があります。


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で指定した値に更新

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>

ブラウザで確認すると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から商品情報を取得して表示しています。

Storeのproductsに保存された情報を表示
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になっていることが確認できます。

カートへ入れると在庫が減ることを確認

さらに”カートへ”ボタンをクリックしていくと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>

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

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

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

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