T-CREATOR

Vue 3 の provide/inject パターン徹底解説

Vue 3 の provide/inject パターン徹底解説

フロントエンド開発において、コンポーネント間でのデータ共有は永続的な課題です。特にVue.jsを使った大規模なアプリケーション開発では、親子関係が複雑になるにつれて、データの受け渡しが困難になっていきます。

Vue 3で導入されたprovide/injectパターンは、この長年の課題に対する優雅な解決策を提供してくれます。今回は、provide/injectパターンの基礎から応用まで、実際のエラーハンドリングも含めて詳しく解説していきます。

背景

Vue.jsにおけるコンポーネント間データ共有の課題

Vue.jsでアプリケーションを開発していると、避けて通れないのがコンポーネント間でのデータ共有です。小規模なアプリケーションでは問題になりませんが、プロジェクトが大きくなるにつれて、データの流れが複雑になっていきます。

従来のVue.jsでは、親コンポーネントから子コンポーネントへはpropsを使い、子から親へはemitを使ってデータをやり取りしていました。これは直接的な親子関係では非常にシンプルで理解しやすい仕組みですね。

propsとemitの限界とバケツリレー問題

しかし、実際の開発現場では、データを何層にもわたって渡す必要が出てきます。例えば、アプリケーションのルートコンポーネントにあるユーザー情報を、5層下の孫コンポーネントで使いたい場合を考えてみてください。

propsだけを使った場合の実装例を見てみましょう:

vue<!-- ルートコンポーネント -->
<template>
  <HeaderComponent :user="currentUser" />
</template>

<script setup lang="ts">
import { ref } from 'vue'

const currentUser = ref({
  id: 1,
  name: 'tanaka',
  role: 'admin'
})
</script>
vue<!-- HeaderComponent.vue -->
<template>
  <NavigationComponent :user="user" />
</template>

<script setup lang="ts">
defineProps<{
  user: {
    id: number
    name: string
    role: string
  }
}>()
</script>

このように、中間のコンポーネントが実際にはユーザー情報を使わないにも関わらず、下位コンポーネントのためだけにpropsを定義し続ける必要があります。これが「バケツリレー問題」と呼ばれる現象です。

provide/injectが解決する問題領域

provide/injectパターンは、このバケツリレー問題を根本的に解決してくれます。上位のコンポーネントでデータを「提供(provide)」し、必要な下位コンポーネントで「注入(inject)」するという、依存性注入の考え方を採用しています。

これにより、中間のコンポーネントを汚すことなく、必要な場所で必要なデータにアクセスできるようになります。まさに開発者の心を軽やかにしてくれる機能ですね。

課題

深いコンポーネント階層でのデータ渡し

現代のWebアプリケーションでは、コンポーネントの階層が深くなることは珍しくありません。例えば、以下のような構造を考えてみてください:

cssApp
├── Layout
│   ├── Header
│   │   ├── Navigation
│   │   │   ├── UserMenu
│   │   │   │   └── UserProfile  ← ここでユーザー情報が必要
│   │   │   └── NotificationBell
│   │   └── SearchBox
│   ├── Sidebar
│   └── Main
│       └── Content

この例では、AppコンポーネントからUserProfileコンポーネントまで6層の深さがあります。従来のprops渡しでは、Layout、Header、Navigation、UserMenuの各コンポーネントすべてでpropsを定義する必要があります。

グローバル状態管理の複雑さ

一方で、VuexやPiniaなどの状態管理ライブラリを導入する選択肢もありますが、小〜中規模のアプリケーションでは過剰な場合があります。状態管理ライブラリは強力ですが、以下のような課題も抱えています:

  • 学習コストが高い
  • 設定が複雑になりがち
  • 小さなデータのためにストアを作るのは大げさ
  • テストの複雑化

従来手法の保守性の問題

バケツリレー問題は保守性にも大きな影響を与えます。propsを何層にも渡していると、以下のような問題が発生します:

typescript// 型定義の重複
interface UserProps {
  user: {
    id: number
    name: string
    role: string
  }
}

// 複数のコンポーネントで同じような定義が必要
// 修正時には全てのファイルを更新する必要がある

特に、データ構造が変更になった場合、中間のすべてのコンポーネントを修正する必要があります。これは開発効率を大幅に下げてしまう要因となります。

また、新しい開発者がプロジェクトに参加した際、なぜこのコンポーネントでこのpropsが必要なのかが分からなくなることもあります。コードの可読性という観点でも課題となっているのです。

解決策

provide/injectの基本概念

provide/injectパターンは、依存性注入(Dependency Injection)の概念をVue.jsに適用したものです。上位コンポーネントでprovideを使ってデータや関数を提供し、下位コンポーネントでinjectを使ってそれらを注入します。

基本的な仕組みを見てみましょう:

vue<!-- 親コンポーネント -->
<template>
  <div>
    <ChildComponent />
  </div>
</template>

<script setup lang="ts">
import { provide, ref } from 'vue'

const user = ref({
  id: 1,
  name: 'yamada',
  role: 'admin'
})

// 'user'というキーでデータを提供
provide('user', user)
</script>

この例では、親コンポーネントがuserというキーでユーザーデータを提供しています。provide関数の第一引数はキー、第二引数は提供する値です。

vue<!-- 子コンポーネント(何層下でも可) -->
<template>
  <div>
    <p>こんにちは、{{ user?.name }}さん</p>
    <p>あなたの権限は{{ user?.role }}です</p>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue'

// 'user'キーで注入されたデータを受け取る
const user = inject('user')
</script>

子コンポーネントでは、inject関数を使って同じキーでデータを取得します。中間のコンポーネントを一切修正する必要がないのが、provide/injectの最大の魅力です。

リアクティブ性の保持方法

provide/injectでは、リアクティブ性も適切に保持されます。上位コンポーネントでrefやreactiveで作成したデータを提供すれば、下位コンポーネントでも自動的に更新が反映されます。

vue<!-- 親コンポーネント -->
<script setup lang="ts">
import { provide, ref } from 'vue'

const counter = ref(0)

// リアクティブな値を提供
provide('counter', counter)

const increment = () => {
  counter.value++
}

// 関数も一緒に提供できる
provide('increment', increment)
</script>
vue<!-- 子コンポーネント -->
<template>
  <div>
    <p>カウント: {{ counter }}</p>
    <button @click="increment">増加</button>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue'

const counter = inject('counter')
const increment = inject('increment')
</script>

この例では、親コンポーネントでカウンターの値を変更すると、子コンポーネントの表示も自動的に更新されます。

TypeScriptでの型安全な実装

TypeScriptを使用する場合、型安全性を保つためにInjectionKeyを使用することを強く推奨します。これにより、コンパイル時に型チェックが行われ、より安全な開発が可能になります。

まず、専用のキーファイルを作成します:

typescript// keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface User {
  id: number
  name: string
  role: 'admin' | 'user' | 'guest'
}

// InjectionKeyで型安全なキーを定義
export const userKey: InjectionKey<Ref<User>> = Symbol('user')
export const incrementKey: InjectionKey<() => void> = Symbol('increment')

InjectionKeyを使った実装は以下のようになります:

vue<!-- 親コンポーネント -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { userKey, incrementKey, type User } from './keys'

const user = ref<User>({
  id: 1,
  name: 'sato',
  role: 'admin'
})

const updateUserRole = () => {
  user.value.role = user.value.role === 'admin' ? 'user' : 'admin'
}

// 型安全なキーで提供
provide(userKey, user)
provide(incrementKey, updateUserRole)
</script>

具体例

基本的なprovide/inject実装

まずは、最もシンプルなprovide/injectの実装から見ていきましょう。テーマ切り替え機能を例に、基本的な使い方を理解していきます。

vue<!-- App.vue -->
<template>
  <div :class="themeClass">
    <header>
      <ThemeToggle />
    </header>
    <main>
      <ContentArea />
    </main>
  </div>
</template>

<script setup lang="ts">
import { provide, ref, computed } from 'vue'
import ThemeToggle from './components/ThemeToggle.vue'
import ContentArea from './components/ContentArea.vue'

const isDarkMode = ref(false)

// テーマ状態と切り替え関数を提供
provide('isDarkMode', isDarkMode)
provide('toggleTheme', () => {
  isDarkMode.value = !isDarkMode.value
})

const themeClass = computed(() => 
  isDarkMode.value ? 'dark-theme' : 'light-theme'
)
</script>

この実装では、アプリケーションのルートでテーマ状態を管理し、子コンポーネントで自由にアクセスできるようにしています。

テーマ切り替えボタンコンポーネントは以下のようになります:

vue<!-- ThemeToggle.vue -->
<template>
  <button @click="toggleTheme" class="theme-toggle">
    {{ isDarkMode ? '🌙' : '☀️' }} 
    {{ isDarkMode ? 'ダークモード' : 'ライトモード' }}
  </button>
</template>

<script setup lang="ts">
import { inject } from 'vue'

const isDarkMode = inject('isDarkMode')
const toggleTheme = inject('toggleTheme')
</script>

リアクティブデータの注入

次に、より実践的な例として、ユーザー認証情報の管理を見てみましょう。ログイン状態やユーザー情報を複数のコンポーネントで共有する場面です。

vue<!-- AuthProvider.vue -->
<template>
  <div>
    <slot />
  </div>
</template>

<script setup lang="ts">
import { provide, ref, computed } from 'vue'

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

const currentUser = ref<User | null>(null)
const isLoading = ref(false)

// 認証状態の計算プロパティ
const isAuthenticated = computed(() => currentUser.value !== null)
const isAdmin = computed(() => currentUser.value?.role === 'admin')

// ログイン処理
const login = async (email: string, password: string) => {
  isLoading.value = true
  try {
    // APIコール(擬似的な実装)
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    })
    
    if (!response.ok) {
      throw new Error('ログインに失敗しました')
    }
    
    const userData = await response.json()
    currentUser.value = userData
  } catch (error) {
    console.error('Login error:', error)
    throw error
  } finally {
    isLoading.value = false
  }
}

// ログアウト処理
const logout = () => {
  currentUser.value = null
}

// 認証関連のデータと関数を提供
provide('currentUser', currentUser)
provide('isAuthenticated', isAuthenticated)
provide('isAdmin', isAdmin)
provide('isLoading', isLoading)
provide('login', login)
provide('logout', logout)
</script>

複数値の管理パターン

複数の関連する値をまとめて管理したい場合は、オブジェクトとして提供する方法が効果的です。以下は、ショッピングカート機能の実装例です:

vue<!-- CartProvider.vue -->
<script setup lang="ts">
import { provide, ref, computed } from 'vue'

interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

const cartItems = ref<CartItem[]>([])

// カート関連の計算プロパティ
const totalItems = computed(() => 
  cartItems.value.reduce((sum, item) => sum + item.quantity, 0)
)

const totalPrice = computed(() => 
  cartItems.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
)

// カート操作関数
const addToCart = (product: Omit<CartItem, 'quantity'>) => {
  const existingItem = cartItems.value.find(item => item.id === product.id)
  
  if (existingItem) {
    existingItem.quantity++
  } else {
    cartItems.value.push({ ...product, quantity: 1 })
  }
}

const removeFromCart = (productId: number) => {
  const index = cartItems.value.findIndex(item => item.id === productId)
  if (index > -1) {
    cartItems.value.splice(index, 1)
  }
}

const updateQuantity = (productId: number, quantity: number) => {
  const item = cartItems.value.find(item => item.id === productId)
  if (item) {
    if (quantity <= 0) {
      removeFromCart(productId)
    } else {
      item.quantity = quantity
    }
  }
}

// カート関連の機能をオブジェクトとしてまとめて提供
const cartState = {
  items: cartItems,
  totalItems,
  totalPrice,
  addToCart,
  removeFromCart,
  updateQuantity
}

provide('cart', cartState)
</script>

この実装により、カートを使用したいコンポーネントでは以下のように簡潔に記述できます:

vue<!-- ProductCard.vue -->
<template>
  <div class="product-card">
    <h3>{{ product.name }}</h3>
    <p>価格: ¥{{ product.price.toLocaleString() }}</p>
    <button @click="handleAddToCart">
      カートに追加
    </button>
  </div>
</template>

<script setup lang="ts">
import { inject } from 'vue'

interface Props {
  product: {
    id: number
    name: string
    price: number
  }
}

const props = defineProps<Props>()
const cart = inject('cart')

const handleAddToCart = () => {
  cart?.addToCart(props.product)
}
</script>

TypeScript + Composition APIでの実装

実際のプロジェクトでは、型安全性を保つためにInjectionKeyとComposition APIを組み合わせる方法が推奨されます。以下は、より本格的な実装例です:

typescript// composables/useAuth.ts
import type { InjectionKey, Ref } from 'vue'

export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

export interface AuthState {
  currentUser: Ref<User | null>
  isAuthenticated: Ref<boolean>
  isLoading: Ref<boolean>
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

// 型安全なInjectionKeyを定義
export const authKey: InjectionKey<AuthState> = Symbol('auth')
vue<!-- AuthProvider.vue -->
<script setup lang="ts">
import { provide, ref, computed } from 'vue'
import { authKey, type User, type AuthState } from '@/composables/useAuth'

const currentUser = ref<User | null>(null)
const isLoading = ref(false)

const isAuthenticated = computed(() => currentUser.value !== null)

const login = async (email: string, password: string) => {
  isLoading.value = true
  try {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    })

    if (!response.ok) {
      const error = await response.json()
      throw new Error(error.message || 'ログインに失敗しました')
    }

    const userData = await response.json()
    currentUser.value = userData
  } catch (error) {
    console.error('認証エラー:', error)
    throw error
  } finally {
    isLoading.value = false
  }
}

const logout = () => {
  currentUser.value = null
  // ローカルストレージのクリアなど
  localStorage.removeItem('auth_token')
}

const authState: AuthState = {
  currentUser,
  isAuthenticated,
  isLoading,
  login,
  logout
}

provide(authKey, authState)
</script>

そして、認証機能を使用するためのComposable関数を作成します:

typescript// composables/useAuth.ts(続き)
import { inject } from 'vue'

export function useAuth() {
  const authState = inject(authKey)
  
  if (!authState) {
    throw new Error('useAuth は AuthProvider の内部で使用してください')
  }
  
  return authState
}

この実装により、コンポーネントでは以下のように型安全に認証機能を使用できます:

vue<!-- LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <label>メールアドレス:</label>
      <input 
        v-model="email" 
        type="email" 
        required 
      />
    </div>
    <div>
      <label>パスワード:</label>
      <input 
        v-model="password" 
        type="password" 
        required 
      />
    </div>
    <button type="submit" :disabled="isLoading">
      {{ isLoading ? 'ログイン中...' : 'ログイン' }}
    </button>
    <div v-if="error" class="error">{{ error }}</div>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useAuth } from '@/composables/useAuth'

const { login, isLoading } = useAuth()

const email = ref('')
const password = ref('')
const error = ref('')

const handleSubmit = async () => {
  error.value = ''
  try {
    await login(email.value, password.value)
  } catch (err) {
    error.value = err instanceof Error ? err.message : 'ログインに失敗しました'
  }
}
</script>

よくあるエラーと対処法

provide/injectを使用する際によく遭遇するエラーと、その対処法を見てみましょう。

エラー1: inject() can only be used inside setup() or functional components

javascript// ❌ 間違った使用例
export default {
  created() {
    const user = inject('user') // エラーが発生
  }
}

このエラーは、setup()関数やComposition API以外でinjectを使用しようとした場合に発生します。

vue<!-- ✅ 正しい実装 -->
<script setup lang="ts">
import { inject } from 'vue'

// setup内でのみ使用可能
const user = inject('user')
</script>

エラー2: Cannot access 'xxx' before initialization

vue<!-- ❌ 間違った使用例 -->
<script setup lang="ts">
import { inject, provide } from 'vue'

// 注入前にprovideしようとしてエラー
const user = inject('user')
provide('processedUser', processUser(user))
</script>

この問題は、inject した値を同じコンポーネント内で加工してprovideしようとした場合に発生することがあります。

vue<!-- ✅ 正しい実装 -->
<script setup lang="ts">
import { inject, provide, computed } from 'vue'

const user = inject('user')

// computedを使用して安全に処理
const processedUser = computed(() => {
  if (!user) return null
  return {
    ...user,
    displayName: `${user.name}様`
  }
})

provide('processedUser', processedUser)
</script>

まとめ

provide/injectの適用場面

provide/injectパターンは以下のような場面で特に威力を発揮します:

適用場面説明従来手法との比較
テーマ管理アプリ全体のテーマ切り替えグローバル変数より型安全
認証状態ユーザーログイン情報の共有Vuexより軽量
多言語対応i18n機能の実装プラグインより柔軟
設定値の共有API設定などの環境設定環境変数より動的

特に、中規模のアプリケーションでは、Vuexのような本格的な状態管理ライブラリを導入する前の選択肢として優秀です。

注意点とベストプラクティス

provide/injectを使用する際は、以下の点に注意してください:

型安全性の確保 InjectionKeyを必ず使用し、TypeScriptの恩恵を最大限に活用しましょう。

typescript// ✅ 推奨
export const userKey: InjectionKey<Ref<User>> = Symbol('user')
provide(userKey, user)

// ❌ 非推奨
provide('user', user)

デフォルト値の設定 injectにはデフォルト値を設定できます。エラーハンドリングの観点から積極的に活用してください。

typescript// デフォルト値を設定
const user = inject(userKey, ref(null))

// デフォルト値を関数で設定
const config = inject(configKey, () => ({
  apiUrl: 'https://api.example.com',
  timeout: 5000
}))

適切なスコープ設定 provideは適切な階層で行い、必要以上に上位でprovideしないよう注意しましょう。

Vue 3のprovide/injectパターンは、開発者にとって新しい可能性を開いてくれる機能です。バケツリレー問題を解決し、よりクリーンで保守性の高いコードを書けるようになります。

ぜひ、あなたのプロジェクトでも積極的に活用して、より良いVue.jsアプリケーションを作り上げてください。小さな改善が積み重なって、大きな開発体験の向上につながることでしょう。

関連リンク