10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Vue3のStore(Pinia)のリアクティブ(reactive)の動きを確認してみた

Last updated at Posted at 2023-03-06

はじめに

リアクティブに関してのPinia Storeの動きが、Vue3のComposition APIの中でどうなるのか?Options APIの時はどう実装すべきか?混乱してしまい、良く分からなくなってしまったので、自身の整理メモとして備忘録を残しておこうと思う。

試したパターンは以下。

  • storeのstateはプリミティブな値
    • Piniaを Composition API で実装
      • コンポーネント側もComposition API
      • 番外編 コンポーネント側はOptions API
    • Piniaを Options API で実装
      • コンポーネント側もOptions API
      • 番外編 コンポーネント側はComposition API
  • storeのstateはオブジェクト
    • Piniaを Composition API で実装
      • コンポーネント側もComposition API
      • 番外編 コンポーネント側はOptions API
    • Piniaを Options API で実装
      • コンポーネント側もOptions API
      • 番外編 コンポーネント側は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されて、それがリアクティブに画面に反映される事が確認できる。まあこれはそうだよね、となると思う。
ezgif.com-video-to-gif (5).gif

上記のコードだと、以下の部分が冗長なので分割代入をしたくなる、気がするが、それを次に試してみようと思う。

			<!-- 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のままになってしまう。
ezgif.com-video-to-gif (6).gif

どうしてこうなってしまうか?だが、順を追ってみていく。

まず、PiniaのdefineStoreはストアインスタンスを返すが、そのインスタンスはリアクティブなオブジェクト(JavaScript プロキシ)である(Vue におけるリアクティビティーの仕組みも参照)。
image.png

リアクティブなオブジェクトであるが故に、以下の引用の通り、分割代入を行ってしまうとリアクティブな状態ではなくなってしまう、という性質を持つ。

リアクティブオブジェクトのプロパティをローカル変数に割り当てたり分割代入した場合、ローカル変数へのアクセスはプロキシーが仕込んだ 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される事が確認できる。
ezgif.com-video-to-gif (5).gif

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>

image.png

ただ、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>

image.png

※この項の最初に示した実装で、テンプレート内で{{ 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のリアクティブなオブジェクトをそのまま利用しているので、以下のように期待通りに動作する。
ezgif.com-video-to-gif (7).gif

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>
10
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?