こんにちは、かつコーチです。
前回は「スロット(slot)で再利用可能なコンポーネントを作る」を解説しました。
今回は、コンポーネントの階層が深くなったときに必ずぶつかる「Props drilling」という問題と、それを解決する provide / inject を扱います。
「孫コンポーネントに値を渡したいだけなのに、途中の子コンポーネントにも全部Propsを書かないといけない」——そんな面倒に心当たりがある人は、ぜひ最後まで読んでみてください。
Props drillingとは何か
深い階層でPropsを中継する辛さ
Props drillingとは、親から孫、さらにひ孫へとデータを渡すために、間のコンポーネントすべてにPropsを中継させる状態を指します。
たとえば App > Layout > Sidebar > UserCard という4階層があり、App が持つユーザー情報を UserCard に渡したいとします。
Propsだけで実現しようとすると、Layout と Sidebar は自分では使わないユーザー情報を、ただ次に渡すためだけに受け取ることになります。
これがコンポーネントが増えるたびに連鎖して、修正コストが跳ね上がる原因になります。
なぜこの問題が起きるのか
Vueのデータフローは基本的に「親から子への一方向」です。
この設計自体は予測しやすく良いものですが、階層が深くなるほど「中継役」のコンポーネントが増えてしまうという副作用があります。
私が実際につまずいたのは、管理画面の設定値(テーマカラーや表示言語)を4階層下のボタンコンポーネントに渡そうとしたときでした。
途中のレイアウトコンポーネントに使いもしないPropsを追加してしまい、後から見た人(未来の自分も含む)が「このPropsは何のために中継されているのか」を追うのに時間がかかってしまったのです。
provide/injectの基本
基本の書き方
provide は先祖コンポーネントで値を提供し、inject は子孫コンポーネントのどこからでもその値を受け取れる仕組みです。
<!-- App.vue(先祖) -->
<script setup lang="ts">
import { provide, ref } from "vue"
const themeColor = ref<string>("blue")
provide("themeColor", themeColor)
</script>
<template>
<Layout />
</template>
<!-- UserCard.vue(孫のさらに下) -->
<script setup lang="ts">
import { inject } from "vue"
const themeColor = inject<string>("themeColor")
</script>
<template>
<p :class="themeColor">ユーザーカードです</p>
</template>
Layout や Sidebar を一切経由せずに、App.vue から UserCard.vue へ直接値が届いているのがポイントです。
途中のコンポーネントは themeColor の存在すら意識する必要がありません。
injectのデフォルト値
inject の第2引数にデフォルト値を渡せます。
<script setup lang="ts">
import { inject } from "vue"
// providerが見つからない場合は"light"が使われる
const theme = inject<string>("themeColor", "light")
</script>
先祖側で provide されていないケース(単体テストなど)でもエラーにならず安全に動くので、実務では基本的にデフォルト値を設定しておくのがおすすめです。
型安全なキーを作る(InjectionKey)
文字列キーの落とし穴
先ほどの例では "themeColor" という文字列をキーに使いましたが、これには弱点があります。
❌ Before:文字列キーをそのまま使う
<!-- App.vue -->
<script setup lang="ts">
import { provide, ref } from "vue"
const themeColor = ref<string>("blue")
provide("themeColor", themeColor) // 文字列キー
</script>
<!-- UserCard.vue -->
<script setup lang="ts">
import { inject } from "vue"
// typoしても型エラーにならず、実行時までバグに気づけない
const theme = inject<string>("themeColer")
</script>
キー名をタイプミスしても、TypeScriptはそれを教えてくれません。
実行してみて初めて「値が undefined になっている」と気づく、というつまずき方をしがちです。
✅ After:InjectionKeyで型とキーを一致させる
// keys.ts
import type { InjectionKey, Ref } from "vue"
export const themeColorKey: InjectionKey<Ref<string>> = Symbol("themeColor")
<!-- App.vue -->
<script setup lang="ts">
import { provide, ref } from "vue"
import { themeColorKey } from "./keys"
const themeColor = ref<string>("blue")
provide(themeColorKey, themeColor)
</script>
<!-- UserCard.vue -->
<script setup lang="ts">
import { inject } from "vue"
import { themeColorKey } from "./keys"
// 型が自動で推論され、キーのtypoもコンパイル時に検出できる
const theme = inject(themeColorKey)
</script>
InjectionKey<Ref<string>> という型付きの Symbol をキーにすることで、provide と inject の型が自動で連動します。
キー名を間違えることもなくなり、theme の型が Ref<string> | undefined だと明示されるので、値が存在しない場合の分岐も書きやすくなります。
リアクティブな値を安全に更新する
子から親の値を書き換えたいとき
inject で受け取った ref は、子コンポーネントから直接書き換えることも技術的には可能です。
ただしそれをやると「どこで値が変更されたか」が追いづらくなり、Props drillingを解消したはずが別のバグの温床になります。
更新用の関数もセットでprovideする
実務では、値の更新を専用の関数に閉じ込めて渡すのが安全です。
<!-- App.vue -->
<script setup lang="ts">
import { provide, ref, readonly } from "vue"
import { themeColorKey, updateThemeColorKey } from "./keys"
const themeColor = ref<string>("blue")
function updateThemeColor(color: string): void {
themeColor.value = color
}
// 値はreadonlyで渡し、更新は専用関数に限定する
provide(themeColorKey, readonly(themeColor))
provide(updateThemeColorKey, updateThemeColor)
</script>
readonly() で包んだ値を提供することで、子孫コンポーネント側からの直接書き換えをVueが警告付きで防いでくれます。
値の変更経路が updateThemeColor の1本に絞られるので、後から挙動を追いやすくなります。
まとめ
この記事のポイント
- Props drillingとは、使わないコンポーネントにまでPropsを中継させ続ける状態のこと
provide/injectを使うと、階層を飛び越えて直接値をやり取りできる- 文字列キーはtypoに弱いため、
InjectionKeyで型とキーを一致させるのがおすすめ - 値を渡すときは
readonly()と更新用関数をセットにすると、変更経路が追いやすくなる
次に読むべき記事
provide / inject はコンポーネントをまたいだ値の共有に便利ですが、アプリ全体で使う状態が増えてくると、それだけでは設計が複雑になりがちです。
次回は、Composition APIのライフサイクルフック(onMounted など)の使い方を解説していきます。
→ 次の記事:ライフサイクルフック(onMounted等)の使い方