前回の記事では、直接的な親子関係にあるコンポーネント間の通信について「props」と「emit」についてまとめました。
しかし、アプリケーションが複雑になり、親子関係を超えた、コンポーネント同士でデータを共有したくなることがあります。例えば、一番上の階層にあるコンポーネントのデータを、5階層下の孫のようなコンポーネントで使いたい場合、中間の3つのコンポーネントは、自分では使わないデータをただ下へ下へと受け渡すためだけにpropsを定義しなければなりません。これは非常に冗長で、コンポーネントの再利用性を損ないます。
今回は、この「プロップスのバケツリレー」問題を解決し、より高度で柔軟な状態管理を実現するための2つの強力な手法、「provide/inject」と状態管理ライブラリ「Pinia」についてまとめたいと思います。
- プロップスバケツリレーを解消するprovideとinject
- アプリケーション全体の状態を管理するPinia
provideとinject:プロップスバケツリレーからの解放
「provide」と「inject」は、Vueに組み込まれた「依存性の注入(Dependency Injection)」の仕組みです 。 これにより、中間のコンポーネントを介さずに、必要なコンポーネントへ直接データを届けることができます。
- provide: 先祖コンポーネントがデータや機能を提供する(provide)。
- inject: 子孫コンポーネントが、階層の深さに関わらず、そのデータや機能を注入して(inject)使用する。
基本的な使い方
「script setup」内でprovide関数とinject関数をインポートして使います。 provideの第一引数は「インジェクションキー」と呼ばれる一意の識別子で、通常は文字列かSymbolを使います 。
親コンポーネント (ParentComponent.vue)
<script setup> import { provide } from 'vue' import ChildComponent from './components/ChildComponent.vue' // 'message'というキーで、'hello from ancestor!'という値を提供する provide('message', 'hello from ancestor!') </script> <template> <p>現在の名前: {{ message }}</p> <ChildComponent /> </template>
子コンポーネント (ChildComponent.vue)
<script setup> import DescendantComponent from './DescendantComponent.vue' </script> <template> <DescendantComponent /> </template>
孫コンポーネント (DescendantComponent.vue)
<script setup> import { inject } from 'vue' // 'message'というキーで提供された値を注入する const message = inject('message') </script> <template> <p>先祖からのメッセージ: {{ message }}</p> </template>
リアクティブなデータの提供
静的な値だけでなく、refやreactiveで作成したリアクティブなデータもprovideできます。これにより、提供元のデータが変更されると、注入先のデータも自動的に更新されます 。この時重要なポイントがあります。
それは、状態を変更するロジックも、状態を提供しているコンポーネント内にまとめることです 。もし注入した側で自由に状態を変更できてしまうと、データの流れが追跡しづらくなるという、propsの直接変更と同じ問題が再発してしまいます。状態(ref)と、その状態を更新する関数をセットでprovideするのが良い設計です。これにより、状態管理の責任の所在が明確になり、メンテナンス性が向上します。
親コンポーネント (ParentComponent.vue)
<script setup> import { ref, provide } from 'vue' import ChildComponent from './components/ChildComponent.vue' const location = ref('Tokyo') function updateLocation() { location.value = 'Osaka' } // 状態そのものと、 provide('location', location) // 状態を更新する関数をセットで提供する provide('updateLocation', updateLocation) </script> <template> <ChildComponent /> </template>
孫コンポーネント (DescendantComponent.vue)
<script setup> import { inject } from 'vue' const location = inject('location') const updateLocation = inject('updateLocation') </script> <template> <p>現在の場所: {{ location }}</p> <button @click="updateLocation">場所を更新</button> </template>
Pinia:アプリケーション全体のグローバルな状態管理
「provide/inject」は、特定のコンポーネントツリー内でデータを共有するには非常に有効です。しかし、ユーザーのログイン状態、テーマ設定、ショッピングカートの中身など、アプリケーションのどこからでもアクセスしたい「グローバルな状態」には、専用の状態管理ライブラリを使うのが一般的です 。
Vue 3における公式の状態管理ライブラリが「Pinia(ピニア)」です 。Vuexの後継と位置づけられており、よりシンプルで直感的なAPIと、強力なTypeScriptサポートが特徴です 。
Piniaの基本
Piniaでは、「ストア」という単位で状態を管理します。ストアは、「state」、「getters」、「actions」から構成されます。
- state: ストアが保持するデータ本体。コンポーネントのdataに相当します。
- getters: stateから派生した値を計算するプロパティ。コンポーネントのcomputedに相当します。
- actions: stateを変更するためのメソッド。非同期処理も可能です。コンポーネントのmethodsに相当します。
ストアの定義(stores/counter.js)
import { defineStore } from 'pinia'; export const useCounterStore = defineStore('counter', { state: () => ({ count: 0, name: 'ハナタレ' }), getters: { doubleCount: (state) => state.count * 2, }, actions: { increment() { this.count++; }, async fetchAndSetName(newName) { // 非同期処理もアクション内で書ける await new Promise(resolve => setTimeout(resolve, 1000)); this.name = newName; } }, });
コンポーネントでの利用
定義したストアは、どのコンポーネントからでもインポートして使用できます 。
<script setup> import { useCounterStore } from '@/stores/counter'; // ストアのインスタンスを取得 const counterStore = useCounterStore(); </script> <template> <div> <p>Count: {{ counterStore.count }}</p> <p>Double Count: {{ counterStore.doubleCount }}</p> <button @click="counterStore.increment">Increment</button> </div> </template>
まとめ
前回と、今回で、Vue.jsのコンポーネント間通信についてまとめてきました。「props/emit」で、 親子間の明確な通信路を構築し、より複雑な階層になる場合は「provide/inject」を使ってより記述をシンプルに行い、グローバルな利用が必要であれば「Pinia」を使うというイメージになるかと思います。今回の記事が今回の記事がVue.jsを使う方の参考になれば幸いです。