はじめに
リアクティブに関してのPinia Storeの動きが、Vue3のComposition APIの中でどうなるのか?Options APIの時はどう実装すべきか?混乱してしまい、良く分からなくなってしまったので、自身の整理メモとして備忘録を残しておこうと思う。
試したパターンは以下。
- storeのstateはプリミティブな値
- Piniaを Composition API で実装
- コンポーネント側もComposition API
- 番外編 コンポーネント側はOptions API
- Piniaを Options API で実装
- コンポーネント側もOptions API
- 番外編 コンポーネント側はComposition API
- Piniaを Composition API で実装
- storeのstateはオブジェクト
- Piniaを Composition API で実装
- コンポーネント側もComposition API
- 番外編 コンポーネント側はOptions API
- Piniaを Options API で実装
- コンポーネント側もOptions API
- 番外編 コンポーネント側はComposition API
- Piniaを Composition API で実装
storeのstateはプリミティブな値
Composition API
PiniaのStoreの実装は以下とする。
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
export default defineStore('counter', () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
const increment = () => {
console.log('increment');
count.value += 1;
console.log(count.value);
};
return { count, doubleCount, increment };
});
reactiveになるパターン use...Store()のstoreをそのまま利用
<script setup>
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
</script>
<template>
<v-main>
<v-container>
<div>count : {{ counterStore.count }}</div>
<div>doubleCount : {{ counterStore.doubleCount }}</div>
<v-btn @click="counterStore.increment()">counter up</v-btn>
</v-container>
</v-main>
</template>
この時、以下の動画の通り、counterはincrement()で+1されて、それがリアクティブに画面に反映される事が確認できる。まあこれはそうだよね、となると思う。
上記のコードだと、以下の部分が冗長なので分割代入をしたくなる、気がするが、それを次に試してみようと思う。
<!-- counterStore.〇〇になっており冗長。単にcount, doubleCount, increment() と指定できるように分割代入をやってみると… -->
<div>count : {{ counterStore.count }}</div>
<div>doubleCount : {{ counterStore.doubleCount }}</div>
<v-btn @click="counterStore.increment()">counter up</v-btn>
reactiveに「ならない」パターン use...Store()の戻りを分割代入で利用
<script setup>
import useCounterStore from '@/stores/counter';
const { count, doubleCount, increment } = useCounterStore();
</script>
<template>
<v-main>
<v-container>
<div>count : {{ count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
</v-container>
</v-main>
</template>
上記のように実装した場合、以下の動画の通り、リアクティブにはならずincrementをしても画面上の数字は0のままになってしまう。
どうしてこうなってしまうか?だが、順を追ってみていく。
まず、PiniaのdefineStoreはストアインスタンスを返すが、そのインスタンスはリアクティブなオブジェクト(JavaScript プロキシ)である(Vue におけるリアクティビティーの仕組みも参照)。
リアクティブなオブジェクトであるが故に、以下の引用の通り、分割代入を行ってしまうとリアクティブな状態ではなくなってしまう、という性質を持つ。
リアクティブオブジェクトのプロパティをローカル変数に割り当てたり分割代入した場合、ローカル変数へのアクセスはプロキシーが仕込んだ get/set をトリガーしなくなるため、リアクティビティーが"切断"されます。
引用元:Vue におけるリアクティビティーの仕組み
また、リアクティブなオブジェクトのプロパティをローカル変数に代入したり、分割代入したり、そのプロパティを関数に渡したりすると、下記に示すようにリアクティブなつながりが失われることとなります
引用元:reactive() の制限
そのため、今回分割代入を行った事でリアクティビィティーが失われ、increment()でcountを+1しても画面上にはそれが反映されなかった。では、どうすればいいか?だが、それは次の項で見ていく。
※以下のようなコードを書くと、useCounterStore()がリアクティブなオブジェクトである事は分かる。以下はリアクティブプロキシ vs. 独自に書かれている通り、reactive()は既存のプロキシに対して reactive() を呼ぶとその同じプロキシが返される、という性質を利用して、useCounterStore()が返しているものがリアクティブなオブジェクト(プロキシ)である事を確認している。
<script setup>
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
console.log(reactive(counterStore) === counterStore); // true ← calling reactive() on a proxy returns itself
</script>
...
reactiveになるパターン storeToRefsで各プロパティをrefに変換し分割代入を利用できるようにする
<script setup>
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
const { count, doubleCount } = storeToRefs(counterStore);
const { increment } = counterStore;
</script>
<template>
<v-main>
<v-container>
<div>count : {{ count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
</v-container>
</v-main>
</template>
上記のように実装した場合、以下の動画の通り、リアクティブな状態が保たれ、incrementをするとcountが+1される事が確認できる。
storeToRefs
が何者か?を見ていく。API ReferenceによるとstoreToRefsは以下のような機能を持つ。
Creates an object of references with all the state, getters, and plugin-added state properties of the store. Similar to toRefs() but specifically designed for Pinia stores so methods and non reactive properties are completely ignored.(ストアのすべての状態、ゲッター、およびプラグインで追加された状態プロパティを含む参照オブジェクトを作成します。toRefs() と似ていますが、特に Pinia ストア用に設計されているので、 メソッドや非反応プロパティは完全に無視されます。)
ここで、toRefs()
というのが出てきたが、これはAPI Referenceにあるようにリアクティブなオブジェクトの各プロパティをrefに変換する関数。そのため以下のように分割代入してもそれぞれがrefなのでリアクティブな状態が保たれたまま利用できる(count.value
のようにしなければならないのは、API Referenceやにあるように、ref()の戻りはrefオブジェクトであり、そのvalue
プロパティがリアクティブであるという仕様になっているため)。
<script setup>
import { toRefs } from 'vue';
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
const { count } = toRefs(counterStore);
console.log(count); // 以下のスクショのようにObjectRef
console.log(count.value); // 0
</script>
ただ、toRefs()
はリアクティブなオブジェクトのプロパティを何でもかんでもrefに変えてしまうので、refに変える対象をstateとgettersにするための特別なtoRefs()相当の関数がstoreToRefs()
という関数の正体。なので、以下のようにconsoleで各プロパティを確認してみると、以下のスクショのようにそれぞれRef、ComputedRefの参照を持っている事が確認できる。
<script setup>
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
const { count, doubleCount } = storeToRefs(counterStore);
console.log(count);
console.log(doubleCount);
</script>
※この項の最初に示した実装で、テンプレート内で{{ count }}
や{{ doubleCount }}
と実装しており、{{ count.value }}
になっていなかった。これはRef Unwrapping in Templatesに書かれている通り、自動でrefのvalue
プロパティを設定していると解釈してくれるため(以下、公式からの引用)。
ref がテンプレートのトップレベルのプロパティとしてアクセスされた場合、それらは自動的に「アンラップ」される
JavaScript内で利用する場合には、以下のように.value
にアクセスする必要があるので注意。
<script setup>
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
const { count, doubleCount } = storeToRefs(counterStore);
...
console.log(count.value); // 0
</script>
【番外編】コンポーネント側をOptions APIで実装する場合
以下のいずれでも動きは上記のComposition APIの時と同じ。
storeToRefsを利用する場合
<script>
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
export default {
setup() {
const counterStore = useCounterStore();
const { count, doubleCount } = storeToRefs(counterStore);
const { increment } = counterStore;
return { count, doubleCount, increment };
},
created() {
console.log(this.count); // 0
console.log(this.doubleCount); // 0
}
};
</script>
<template>
<v-main>
<v-container>
<div>count : {{ count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
</v-container>
</v-main>
</template>
storeToRefsを利用しない場合
<script>
import useCounterStore from '@/stores/counter';
export default {
setup() {
const counterStore = useCounterStore();
const { increment } = counterStore;
return { counterStore, increment };
},
computed: {
count() {
return this.counterStore.count;
},
doubleCount() {
return this.counterStore.doubleCount;
}
},
created() {
console.log(this.count);
console.log(this.doubleCount);
}
};
</script>
<template>
<v-main>
<v-container>
<div>count : {{ count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
</v-container>
</v-main>
</template>
※以下のような実装では分割代入でリアクティビティーが失われるので期待通りに動作しない(incrementをしても画面のcountは0のままになる)
<script>
import useCounterStore from '@/stores/counter';
export default {
setup() {
const counterStore = useCounterStore();
const { count, doubleCount, increment } = counterStore;
return { count, doubleCount, increment };
},
created() {
console.log(this.count);
console.log(this.doubleCount);
}
};
</script>
<template>
省略
</template>
Options API
PiniaのStoreの実装は以下とする。
import { defineStore } from 'pinia';
export default defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
console.log('increment');
this.count += 1;
console.log(this.count);
}
}
});
reactiveになるパターン use...Store()のstoreをそのまま利用
#storetorefsを利用しない場合の実装と全く同じ。もちろん動作は期待通り(incrementごとにcountが+1される)。
※count, doubleCountを分割代入する実装(以下)だと、#storetorefsを利用しない場合で触れたように期待通りの動作にならない。
<script>
import useCounterStore from '@/stores/counter';
export default {
setup() {
const counterStore = useCounterStore();
const { count, doubleCount, increment } = counterStore;
return { count, doubleCount, increment };
},
...
};
</script>
reactiveになるパターン storeToRefsで各プロパティをrefに変換し分割代入を利用できるようにする
storeToRefsを利用する場合の実装と全く同じ。
【番外編】コンポーネント側をComposition APIで実装する場合
reactiveになるパターン use...Store()のstoreをそのまま利用やreactiveになるパターン storeToRefsで各プロパティをrefに変換し分割代入を利用できるようにするの実装と全く同じ。
storeのstateはオブジェクト
Composition API
PiniaのStoreの実装は以下とする。
import { computed, ref, toRefs } from 'vue';
import { defineStore } from 'pinia';
export default defineStore('counter', () => {
const pageRef = ref({ count: 0, list: ['hoge', 'foo'] });
const doubleCount = computed(() => pageRef.value.count * 2);
const page = computed(() => pageRef.value);
const increment = () => {
console.log('increment');
pageRef.value.count += 1;
console.log(pageRef.value.count);
};
const pushList = () => {
pageRef.value.list.push('test');
};
return { pageRef, page, doubleCount, increment, pushList };
});
reactiveになるパターン use...Store()のstoreをそのまま利用
【番外編】コンポーネント側をComposition APIで実装する場合の「reactiveになるパターン use...Store()のstoreをそのまま利用」と全く同じ。
reactiveになるパターン storeToRefsで各プロパティをrefに変換し分割代入を利用できるようにする
【番外編】コンポーネント側をComposition APIで実装する場合の「reactiveになるパターン storeToRefsで各プロパティをrefに変換し分割代入を利用できるようにする」と全く同じ。
※reactiveになるパターン② toRefs()を利用してreactive()をプレーンオブジェクトに変換に変換の実装方法も可能。
【番外編】コンポーネント側をOptions APIで実装する場合
以下のいずれでも動きは上記のComposition APIの時と同じ。
storeToRefsを利用する場合
<script>
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
export default {
setup() {
const counterStore = useCounterStore();
const { page, doubleCount } = storeToRefs(counterStore);
const { increment, pushList } = counterStore;
return { page, doubleCount, increment, pushList };
}
};
</script>
<template>
<v-main>
<v-container>
<div>count : {{ page.count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
<v-btn @click="pushList()">push list</v-btn>
<ul>
<li v-for="title of page.list" :key="title">
{{ title }}
</li>
</ul>
</v-container>
</v-main>
</template>
※toRefsを利用する場合は、以下のように実装する事もできる。
<script>
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
export default {
setup() {
const counterStore = useCounterStore();
const { page, doubleCount } = storeToRefs(counterStore);
const { increment, pushList } = counterStore;
const { count, list } = toRefs(page.value);
return { count, list, doubleCount, increment, pushList };
}
};
</script>
<template>
<v-main>
<v-container>
<div>count : {{ count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
<v-btn @click="pushList()">push list</v-btn>
<ul>
<li v-for="title of list" :key="title">
{{ title }}
</li>
</ul>
</v-container>
</v-main>
</template>
storeToRefsを利用しない場合
<script>
import useCounterStore from '@/stores/counter';
export default {
setup() {
const counterStore = useCounterStore();
const { increment, pushList } = counterStore;
return { counterStore, increment, pushList };
}
computed: {
count() {
return this.counterStore.page.count;
},
list() {
return this.counterStore.page.list;
},
doubleCount() {
return this.counterStore.doubleCount;
}
}
};
</script>
<template>
省略
</template>
Options API
PiniaのStoreの実装は以下とする。
import { defineStore } from 'pinia';
export default defineStore('counter', {
state: () => ({ pageObj: { count: 0, list: ['hoge', 'foo'] } }),
getters: {
doubleCount: (state) => state.page.count * 2,
page: (state) => state.pageObj
},
actions: {
increment() {
console.log('increment');
this.page.count += 1;
console.log(this.page.count);
},
pushList() {
this.page.list.push('test');
}
}
});
reactiveになるパターン
【番外編】コンポーネント側をOptions APIで実装する場合と全く同じ。
【番外編】コンポーネント側をComposition APIで実装する場合
reactiveになるパターン use...Store()のstoreをそのまま利用
<script setup>
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
</script>
<template>
<v-main>
<v-container>
<div>count : {{ counterStore.page.count }}</div>
<div>doubleCount : {{ counterStore.doubleCount }}</div>
<v-btn @click="counterStore.increment()">counter up</v-btn>
<v-btn @click="counterStore.pushList()">push list</v-btn>
<ul>
<li v-for="title of counterStore.page.list" :key="title">
{{ title }}
</li>
</ul>
</v-container>
</v-main>
</template>
上記の実装はかなり冗長だが、以下はstoreのリアクティブなオブジェクトをそのまま利用しているので、以下のように期待通りに動作する。
reactiveになるパターン storeToRefsで各プロパティをrefに変換し分割代入を利用できるようにする
<script setup>
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
const { page, doubleCount } = storeToRefs(counterStore);
const { increment, pushList } = counterStore;
</script>
<template>
<v-main>
<v-container>
<div>count : {{ page.count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
<v-btn @click="pushList()">push list</v-btn>
<ul>
<li v-for="title of page.list" :key="title">
{{ title }}
</li>
</ul>
</v-container>
</v-main>
</template>
上記のように実装してもreactiveになるパターン use...Store()のstoreをそのまま利用と同じように期待通りに動作する。もう少し工夫するとすれば、ref() と共に使うリアクティブな変数に以下のように書かれている通り、ref()でオブジェクト型を保持する場合のvalue
プロパティの値は、リアクティブなオブジェクト(reactive())になる。
また、オブジェクト型を保持する場合、ref は .value を reactive() で自動的に変換します。
そのため、toRefs()を利用して、reactive()で生成されたオブジェクト(リアクティブなオブジェクト)の各プロパティをrefに変換し、分割代入を可能にする事もできる。具体的な実装は以下。
<script setup>
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
const { page, doubleCount } = storeToRefs(counterStore);
const { count, list } = toRefs(page.value);
const { increment, pushList } = counterStore;
</script>
<template>
<v-main>
<v-container>
<div>count : {{ count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
<v-btn @click="pushList()">push list</v-btn>
<ul>
<li v-for="title of list" :key="title">
{{ title }}
</li>
</ul>
</v-container>
</v-main>
</template>
reactiveになるパターン② toRefs()を利用してreactive()をプレーンオブジェクトに変換に変換
公式に以下のように書かれている通り、ref()にオブジェクトが渡されたとき、.value
プロパティはreactive()なオブジェクトになる。
また、オブジェクト型を保持する場合、ref は .value を reactive() で自動的に変換します
そのため、toRefs()を利用して、reactive()なオブジェクトの各プロパティを元のオブジェクトの対応するプロパティを指すref()に変換する事ができ、今回で言うとpage
はreactive()なオブジェクトなので実装としては以下のようにする事もできる。
<script setup>
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia';
import useCounterStore from '@/stores/counter';
const counterStore = useCounterStore();
const { page, doubleCount } = storeToRefs(counterStore);
const { increment, pushList } = counterStore;
const { count, list } = toRefs(page.value);
</script>
<template>
<v-main>
<v-container>
<div>count : {{ count }}</div>
<div>doubleCount : {{ doubleCount }}</div>
<v-btn @click="increment()">counter up</v-btn>
<v-btn @click="pushList()">push list</v-btn>
<ul>
<li v-for="title of list" :key="title">
{{ title }}
</li>
</ul>
</v-container>
</v-main>
</template>