Vue 3 × TypeScriptで理解するProvide / InjectとInjectionKey
Vue.jsで複数階層のコンポーネント間にデータを渡す場合、基本的にはPropsを使用します。
しかし、親コンポーネントから孫・ひ孫コンポーネントへデータを渡す場合、途中のコンポーネントでもPropsを受け取り、さらに下のコンポーネントへ渡す必要があります。
このようなPropsの受け渡しが続く状態は、一般的にProps drillingと呼ばれます。
Vue 3では、Provide / Injectを利用することで、途中のコンポーネントを経由せずに、子孫コンポーネントへデータや関数を共有できます。
本記事では、以下の内容について解説します。
- Provide / Injectの基本
- TypeScriptでの型指定
- InjectionKeyの使い方
- 値をリアクティブに共有する方法
- 実際のコンポーネント構成例
- 利用時の注意点
Provide / Injectとは
Provide / Injectは、祖先コンポーネントから子孫コンポーネントへ値を共有するための仕組みです。
値を提供する側でprovide()を使用し、値を受け取る側でinject()を使用します。
App.vue
└── ParentComponent.vue
└── ChildComponent.vue
└── GrandChildComponent.vue
例えば、App.vueで提供した値は、GrandChildComponent.vueから直接取得できます。
途中にあるParentComponent.vueやChildComponent.vueでPropsを受け渡す必要はありません。
Provideの基本
値を提供する側では、Vueからprovideをインポートします。
<script setup lang="ts">
import { provide } from 'vue'
provide('message', 'Hello Provide / Inject')
</script>
<template>
<div>
<h1>親コンポーネント</h1>
</div>
</template>
provide()の第1引数にはキー、第2引数には共有したい値を指定します。
provide('message', 'Hello Provide / Inject')
この例では、messageというキーで文字列を提供しています。
Injectの基本
値を受け取る側では、injectを使用します。
<script setup lang="ts">
import { inject } from 'vue'
const message = inject('message')
</script>
<template>
<div>
<p>{{ message }}</p>
</div>
</template>
inject()に、provide()で指定したものと同じキーを渡します。
const message = inject('message')
これにより、祖先コンポーネントが提供した値を取得できます。
TypeScript利用時の問題点
文字列をキーとしてinject()を使用した場合、取得結果にはundefinedの可能性があります。
const message = inject<string>('message')
この場合、messageの型は次のようになります。
string | undefined
なぜなら、対応するprovide()が存在しない可能性があるためです。
そのため、テンプレートや処理内で使用する際には、undefinedを考慮する必要があります。
if (message) {
console.log(message)
}
または、デフォルト値を指定できます。
const message = inject<string>('message', 'デフォルトメッセージ')
デフォルト値を指定すると、messageはstringとして扱えます。
文字列キーを使う場合の問題
次のように文字列をキーとして使用することもできます。
provide('user', {
id: 1,
name: '山田太郎'
})
const user = inject<User>('user')
ただし、文字列キーにはいくつか問題があります。
- キー名の入力ミスが起こる可能性がある
- 別の場所で同じキー名が使われる可能性がある
-
provide側とinject側の型が自動的に関連付けられない - 大規模なアプリケーションでは管理しにくい
例えば、次のようにキー名を間違えても、TypeScriptは検知できません。
provide('user', user)
const user = inject<User>('users')
userとusersでキーが異なるため、値を取得できません。
この問題を避けるために使用できるのが、InjectionKeyです。
InjectionKeyとは
InjectionKeyは、Provide / Injectで使用するキーに型情報を持たせるための仕組みです。
InjectionKeyを使用すると、provide()で渡す値とinject()で受け取る値の型を統一できます。
内部的にはSymbolをキーとして使用します。
import type { InjectionKey } from 'vue'
InjectionKeyの定義
まず、共有するデータの型を定義します。
export type User = {
id: number
name: string
}
次に、InjectionKeyを定義します。
import type { InjectionKey } from 'vue'
import type { User } from './types'
export const userKey: InjectionKey<User> = Symbol('user')
InjectionKey<User>と指定することで、このキーに対応する値はUser型であることを表現できます。
ファイル構成例
キーや型を専用ファイルへ分けておくと管理しやすくなります。
src
├── components
│ ├── ParentComponent.vue
│ └── UserProfile.vue
├── keys
│ └── userKey.ts
├── types
│ └── user.ts
└── App.vue
型定義ファイル
src/types/user.ts
export type User = {
id: number
name: string
email: string
}
InjectionKeyの定義ファイル
src/keys/userKey.ts
import type { InjectionKey } from 'vue'
import type { User } from '../types/user'
export const userKey: InjectionKey<User> = Symbol('user')
Symbol()を使うことで、同じ名前のキーが別の場所で作成されても衝突しません。
Symbol('user') === Symbol('user')
上記の結果はfalseになります。
それぞれ別のSymbolとして扱われるためです。
Provideする側
App.vue
<script setup lang="ts">
import { provide } from 'vue'
import { userKey } from './keys/userKey'
import type { User } from './types/user'
import UserProfile from './components/UserProfile.vue'
const user: User = {
id: 1,
name: '山田太郎',
email: 'yamada@example.com'
}
provide(userKey, user)
</script>
<template>
<main>
<h1>ユーザー情報</h1>
<UserProfile />
</main>
</template>
userKeyはInjectionKey<User>として定義されているため、異なる型の値を渡すとTypeScriptのエラーになります。
provide(userKey, '山田太郎')
この場合、userKeyはUser型を要求しているため、文字列を渡すことはできません。
Injectする側
UserProfile.vue
<script setup lang="ts">
import { inject } from 'vue'
import { userKey } from '../keys/userKey'
const user = inject(userKey)
</script>
<template>
<section v-if="user">
<p>ID:{{ user.id }}</p>
<p>名前:{{ user.name }}</p>
<p>メールアドレス:{{ user.email }}</p>
</section>
<p v-else>
ユーザー情報を取得できませんでした。
</p>
</template>
userKeyに型情報が含まれているため、次のように型引数を指定する必要はありません。
const user = inject(userKey)
userの型は自動的に次のようになります。
User | undefined
リアクティブな値を共有する
通常のオブジェクトをprovide()した場合、値を更新してもリアクティブな変更として扱われないケースがあります。
リアクティブな状態を共有したい場合は、ref()やreactive()を使用します。
Refを共有する例
まず、共有する型を定義します。
import type { InjectionKey, Ref } from 'vue'
export const countKey: InjectionKey<Ref<number>> = Symbol('count')
提供する側では、ref()で値を定義します。
<script setup lang="ts">
import { provide, ref } from 'vue'
import { countKey } from './keys/countKey'
import CounterDisplay from './components/CounterDisplay.vue'
const count = ref(0)
provide(countKey, count)
</script>
<template>
<div>
<button @click="count++">
親側でカウントアップ
</button>
<CounterDisplay />
</div>
</template>
受け取る側では、同じcountKeyを使用します。
<script setup lang="ts">
import { inject } from 'vue'
import { countKey } from '../keys/countKey'
const count = inject(countKey)
</script>
<template>
<p v-if="count">
カウント:{{ count }}
</p>
</template>
テンプレート内では、refの.valueは自動的に展開されます。
そのため、次のように記述できます。
{{ count }}
JavaScriptやTypeScriptの処理内では、.valueが必要です。
if (count) {
console.log(count.value)
}
状態と更新処理をまとめて共有する
状態だけでなく、更新用の関数もProvide / Injectで共有できます。
例えば、Todoリストの状態と追加処理を共有する場合を考えます。
Todoの型定義
src/types/todo.ts
export type Todo = {
id: number
title: string
completed: boolean
}
Contextの型を定義する
import type { Ref } from 'vue'
import type { Todo } from './todo'
export type TodoContext = {
todos: Ref<Todo[]>
addTodo: (title: string) => void
toggleTodo: (id: number) => void
}
InjectionKeyを定義する
src/keys/todoKey.ts
import type { InjectionKey } from 'vue'
import type { TodoContext } from '../types/todoContext'
export const todoKey: InjectionKey<TodoContext> = Symbol('todo')
Provideする側
<script setup lang="ts">
import { provide, ref } from 'vue'
import TodoList from './components/TodoList.vue'
import { todoKey } from './keys/todoKey'
import type { Todo } from './types/todo'
const todos = ref<Todo[]>([
{
id: 1,
title: 'Vue.jsを学習する',
completed: false
}
])
const addTodo = (title: string): void => {
const trimmedTitle = title.trim()
if (!trimmedTitle) {
return
}
todos.value.push({
id: Date.now(),
title: trimmedTitle,
completed: false
})
}
const toggleTodo = (id: number): void => {
const todo = todos.value.find((item) => item.id === id)
if (!todo) {
return
}
todo.completed = !todo.completed
}
provide(todoKey, {
todos,
addTodo,
toggleTodo
})
</script>
<template>
<TodoList />
</template>
Injectする側
<script setup lang="ts">
import { inject, ref } from 'vue'
import { todoKey } from '../keys/todoKey'
const todoContext = inject(todoKey)
const inputTitle = ref('')
const handleAddTodo = (): void => {
if (!todoContext) {
return
}
todoContext.addTodo(inputTitle.value)
inputTitle.value = ''
}
</script>
<template>
<section v-if="todoContext">
<div>
<input
v-model="inputTitle"
type="text"
placeholder="Todoを入力"
>
<button @click="handleAddTodo">
追加
</button>
</div>
<ul>
<li
v-for="todo in todoContext.todos.value"
:key="todo.id"
>
<label>
<input
type="checkbox"
:checked="todo.completed"
@change="todoContext.toggleTodo(todo.id)"
>
<span :class="{ completed: todo.completed }">
{{ todo.title }}
</span>
</label>
</li>
</ul>
</section>
<p v-else>
Todo情報を取得できませんでした。
</p>
</template>
<style scoped>
.completed {
text-decoration: line-through;
}
</style>
このように、データと操作用の関数をまとめて共有できます。
Injectした値のundefinedを処理する方法
inject()の戻り値には、基本的にundefinedの可能性があります。
対応方法はいくつかあります。
v-ifで確認する
<template>
<div v-if="user">
{{ user.name }}
</div>
</template>
最も安全で分かりやすい方法です。
デフォルト値を指定する
const user = inject(userKey, {
id: 0,
name: 'ゲスト',
email: ''
})
Providerが存在しない場合は、指定したデフォルト値が使用されます。
エラーを発生させる
Providerが必須である場合は、値が存在しない時点でエラーを発生させる方法もあります。
const user = inject(userKey)
if (!user) {
throw new Error('userKeyがprovideされていません')
}
この処理以降では、userはUser型として扱われます。
共通関数を作成する
同じチェックを複数のコンポーネントで行う場合は、Composableとして切り出すと便利です。
import { inject } from 'vue'
import { userKey } from '../keys/userKey'
export const useUser = () => {
const user = inject(userKey)
if (!user) {
throw new Error('useUserはuserKeyのProvider配下で使用してください')
}
return user
}
コンポーネント側では、次のように使用できます。
<script setup lang="ts">
import { useUser } from '../composables/useUser'
const user = useUser()
</script>
<template>
<p>{{ user.name }}</p>
</template>
この方法では、各コンポーネントで毎回undefinedを確認する必要がありません。
readonlyを使用する
Inject側から状態を自由に変更させたくない場合は、readonly()を使用できます。
import { provide, readonly, ref } from 'vue'
const count = ref(0)
const increment = (): void => {
count.value++
}
provide(countKey, {
count: readonly(count),
increment
})
状態の更新は、提供側が用意した関数を通して行います。
context.increment()
この構成にすると、状態変更の責任をProvider側に集約できます。
context.count.value++
のようにInject側から直接変更することを防ぎやすくなります。
PropsとProvide / Injectの使い分け
PropsとProvide / Injectには、それぞれ適した用途があります。
Propsが適しているケース
- 親子間でデータを渡す
- コンポーネントの依存関係を明確にしたい
- 再利用可能なコンポーネントを作りたい
- データの流れを追いやすくしたい
<UserProfile :user="user" />
親子間の単純な受け渡しであれば、基本的にはPropsを使用します。
Provide / Injectが適しているケース
- 複数階層のコンポーネントへデータを渡したい
- テーマや認証情報などを子孫全体で共有したい
- Props drillingを避けたい
- 特定のコンポーネントツリー内で状態を共有したい
具体例としては、以下のような用途があります。
- ログインユーザー情報
- テーマ設定
- 言語設定
- フォーム全体の状態
- タブやアコーディオンの内部状態
- 共通の操作関数
Piniaとの違い
Provide / InjectとPiniaは、どちらも状態共有に利用できますが、用途が異なります。
Provide / Inject
特定のコンポーネントツリー内で状態を共有する場合に向いています。
FormProvider
└── InputGroup
└── CustomInput
このフォーム内だけで必要な状態であれば、Provide / Injectが適しています。
Pinia
アプリケーション全体で利用するグローバルな状態管理に向いています。
App全体
├── Header
├── Main
└── MyPage
ログインユーザー情報やショッピングカートなど、複数の画面から利用する状態はPiniaの方が管理しやすい場合があります。
Provide / Injectですべての状態を管理しようとすると、どこから値が提供されているか分かりにくくなるため、用途に応じた使い分けが必要です。
Provide / Injectを使う際の注意点
使いすぎるとデータの流れが分かりにくくなる
Propsはテンプレートを見るだけで、どの値が渡されているか確認できます。
<UserProfile :user="user" />
一方、Injectは祖先コンポーネントから暗黙的に値を受け取ります。
const user = inject(userKey)
どこでProvideされているかを探す必要があるため、使いすぎると依存関係が分かりにくくなります。
キーは共通ファイルで管理する
Provide側とInject側で別々にSymbolを作成してはいけません。
provide(Symbol('user'), user)
const user = inject(Symbol('user'))
同じuserという説明を持っていても、異なるSymbolとして扱われます。
必ず同じキーをインポートして使用します。
import { userKey } from './keys/userKey'
状態の変更箇所を限定する
Inject側から直接状態を更新できる設計にすると、変更箇所が分散します。
そのため、更新用関数をProvide側に定義する方法が安全です。
provide(todoKey, {
todos: readonly(todos),
addTodo,
toggleTodo
})
まとめ
Vue 3のProvide / Injectを使用すると、祖先コンポーネントから子孫コンポーネントへ、途中のコンポーネントを経由せずに値を共有できます。
特にTypeScriptを使用する場合は、文字列キーではなくInjectionKeyを利用することで、型安全に実装できます。
export const userKey: InjectionKey<User> = Symbol('user')
Provide側では、キーに対応する型の値を提供します。
provide(userKey, user)
Inject側では、キーから型が推論されます。
const user = inject(userKey)
重要なポイントは次のとおりです。
- 単純な親子間のデータ受け渡しにはPropsを使用する
- 複数階層で値を共有する場合はProvide / Injectを検討する
- TypeScriptではInjectionKeyを使用する
- リアクティブな値には
refやreactiveを使用する - 状態更新用の関数もまとめてProvideできる
- Inject側で
undefinedを考慮する - 状態の直接変更を防ぐ場合は
readonlyを使用する - アプリ全体の状態管理にはPiniaも検討する
Provide / Injectは便利な仕組みですが、すべてのデータ共有に使用するのではなく、PropsやPiniaと役割を分けることが重要です。
補足:Symbol()とSymbol.for()の違い
Vue 3のProvide / Injectでは、InjectionKeyとしてSymbol()を使用することが一般的です。
export const userKey: InjectionKey<User> = Symbol("user-key");
通常はこの書き方で問題ありません。
Symbol()の特徴
Symbol()は呼び出すたびに新しい一意のSymbolを生成します。
const key1 = Symbol("user");
const key2 = Symbol("user");
console.log(key1 === key2); // false
同じ説明文字列を指定しても、別のSymbolになります。
Symbol.for()の特徴
Symbol.for()はグローバルなSymbolレジストリを利用します。
同じキー名で呼び出すと、常に同じSymbolが返されます。
const key1 = Symbol.for("user");
const key2 = Symbol.for("user");
console.log(key1 === key2); // true
開発時はSymbol.for()が役立つ場合がある
Viteなどでは**HMR(Hot Module Replacement)**によって、ページ全体をリロードせず、変更したモジュールだけを再読み込みできます。
このとき、
export const userKey = Symbol("user-key");
としていると、モジュールの再読み込み時に新しいSymbolが生成される可能性があります。
その結果、provide()とinject()で異なるSymbolを参照してしまい、値を取得できないケースがあります。
そのような場合は、
export const userKey = Symbol.for("user-key");
としておくことで、同じキー名から同じSymbolを取得できるため、HMR時のトラブルを避けやすくなります。
どちらを使うべき?
| 利用シーン | おすすめ |
|---|---|
| 一般的なVueアプリ | Symbol() |
| HMRを利用した開発環境 |
Symbol.for()も検討 |
| ライブラリ間でキーを共有する | Symbol.for() |
| モジュールをまたいで同じSymbolを使いたい | Symbol.for() |
まとめ
Symbol()は毎回新しいSymbolを生成するため、一意性を保証したい場合に適しています。一方、Symbol.for()は同じ名前から常に同じSymbolを取得できるため、HMR環境や複数モジュールでキーを共有するケースで役立ちます。
普段のVueアプリ開発ではSymbol()で十分なケースがほとんどですが、開発環境やライブラリ開発ではSymbol.for()を選択することで、より安定した動作につながる場合があります。