【Vue】provide/injectでProps drillingを解消する

JavaScript

こんにちは、かつコーチです。

前回は「スロット(slot)で再利用可能なコンポーネントを作る」を解説しました。

今回は、コンポーネントの階層が深くなったときに必ずぶつかる「Props drilling」という問題と、それを解決する provide / inject を扱います。

「孫コンポーネントに値を渡したいだけなのに、途中の子コンポーネントにも全部Propsを書かないといけない」——そんな面倒に心当たりがある人は、ぜひ最後まで読んでみてください。

Props drillingとは何か

深い階層でPropsを中継する辛さ

Props drillingとは、親から孫、さらにひ孫へとデータを渡すために、間のコンポーネントすべてにPropsを中継させる状態を指します。

たとえば App > Layout > Sidebar > UserCard という4階層があり、App が持つユーザー情報を UserCard に渡したいとします。

Propsだけで実現しようとすると、LayoutSidebar は自分では使わないユーザー情報を、ただ次に渡すためだけに受け取ることになります。

これがコンポーネントが増えるたびに連鎖して、修正コストが跳ね上がる原因になります。

なぜこの問題が起きるのか

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>

LayoutSidebar を一切経由せずに、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 をキーにすることで、provideinject の型が自動で連動します。

キー名を間違えることもなくなり、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等)の使い方

タイトルとURLをコピーしました