On this page

provide() and inject(): dependency injection in Vue 3

12 min read TextCh. 3 — Composition

The prop drilling problem

Props are the primary mechanism for passing data between components. However, when data needs to reach a deeply nested component, you must pass it through every intermediate component in the chain — even if those intermediary components do not use it themselves. This is called prop drilling.

App                (has theme: 'dark')
  └── Layout       (passes theme as prop — doesn't use it)
       └── Sidebar (passes theme as prop — doesn't use it)
            └── NavLink (NEEDS theme — finally uses it!)

Every time the data structure changes, you must update all the intermediate components. This tight coupling makes components harder to reuse and refactor.

provide() and inject()

Vue's solution is the provide / inject pair. An ancestor component provides a value, and any descendant — no matter how deep — can inject it directly, skipping all intermediate components.

// Ancestor
import { provide } from 'vue'
provide('key', value)

// Any descendant
import { inject } from 'vue'
const value = inject('key')

There is no limit on depth. The descendant does not need to know which ancestor provided the value.

InjectionKey — type-safe injection

String keys work but lose type information. The recommended pattern is using InjectionKey<T> from Vue, which is a typed Symbol:

// injection-keys.ts — share this file between provider and consumer
import type { InjectionKey, Ref } from 'vue'

interface UserContext {
  user: Ref<User | null>
  logout: () => void
}

export const userKey: InjectionKey<UserContext> = Symbol('user')

When you provide(userKey, {...}), TypeScript enforces the shape. When you inject(userKey), TypeScript infers UserContext | undefined automatically.

Providing reactive values

You can provide any reactive value — ref, reactive, computed, or a plain object containing them:

// Provider
const count = ref(0)
const doubled = computed(() => count.value * 2)

provide('counter', {
  count: readonly(count), // expose as readonly to prevent direct mutation
  doubled,
  increment: () => count.value++,
})

// Consumer
const { count, doubled, increment } = inject('counter')!

Best practice: provide the state as readonly() and expose mutation functions. This keeps mutations centralized in the provider.

Default values for inject()

inject() returns T | undefined unless you provide a default value. Provide a fallback for optional contexts:

// Option 1: default value (non-reactive)
const theme = inject('theme', 'light')

// Option 2: factory function for expensive defaults
const config = inject('config', () => ({ debug: false }), true)
// The third argument `true` means "treat second arg as a factory"

// Option 3: throw if not found
const required = inject('mustExist')
if (!required) throw new Error('This component must be inside <Provider>')

App-level provide

You can provide values at the application level in main.ts, making them available to every component:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// App-level provides — available everywhere
app.provide('apiBaseUrl', 'https://api.example.com')
app.provide('version', '2.0.0')

app.mount('#app')

Provide/inject in composables

The pattern becomes especially powerful when wrapped in composables:

// useTheme.ts
import { provide, inject, ref, readonly } from 'vue'
import type { InjectionKey } from 'vue'

type Theme = 'light' | 'dark'

interface ThemeContext {
  theme: Readonly<Ref<Theme>>
  setTheme: (t: Theme) => void
}

const themeKey: InjectionKey<ThemeContext> = Symbol('theme')

// Call in the providing ancestor
export function provideTheme() {
  const theme = ref<Theme>('light')
  function setTheme(t: Theme) { theme.value = t }
  provide(themeKey, { theme: readonly(theme), setTheme })
}

// Call in any descendant
export function useTheme() {
  const ctx = inject(themeKey)
  if (!ctx) throw new Error('useTheme must be used inside a ThemeProvider')
  return ctx
}

Usage:

// In ThemeProvider.vue setup:
provideTheme()

// In any descendant:
const { theme, setTheme } = useTheme()

This composable pattern eliminates string-key typos and centralizes the injection logic.

When to use provide/inject vs other patterns

Pattern Best for
Props Direct parent-child communication, explicit data flow
Emits Child-to-parent events, explicit feedback
provide/inject Subtree-scoped shared context (forms, themes, localization)
Pinia Global app state, DevTools, persistence
Composables Reusable logic without shared state

A form context is the canonical example for provide/inject — the <Form> component provides validation state and submission handlers, and deeply nested <Field> components inject them without prop drilling:

// Form.vue
const formContext = reactive({
  values: {},
  errors: {},
  isSubmitting: false,
  register: (name: string) => { /* ... */ },
})
provide(formContextKey, formContext)

// Field.vue (deeply nested)
const form = inject(formContextKey)!
// Access form.values, form.errors, form.register

Practice

  1. Build a ThemeProvider: Create a ThemeProvider.vue that provides a theme ref and a toggleTheme function via InjectionKey. Create a ThemeToggle.vue deeply nested inside it that calls inject() to toggle the theme.
  2. Form context: Create a simple form with a Form.vue parent and three Field.vue children. Use provide/inject so each Field can register itself, access the current value, and display validation errors without any props.
  3. App-level provide: In main.ts, provide the app version string. Display it in a Footer.vue component using inject().

In the next lesson, we will explore Vue Router — the official routing library for building multi-page Vue applications with navigation guards and lazy loading.

Always use InjectionKey for type safety
String-based keys like provide('theme', value) work but lose type safety. Use InjectionKey<T> with a Symbol — this ensures inject() infers the correct return type automatically and prevents key collisions.
Provide at the right level
provide() makes a value available to all descendants — not just direct children. Provide at the lowest possible ancestor that needs to share the state. App-level provides (in main.ts via app.provide()) are available everywhere.
provide/inject vs Pinia
provide/inject is best for sharing context within a specific subtree (e.g., a form, a modal, a theme provider). For truly global application state that needs DevTools integration and persistence, use Pinia (covered in lesson 10).
<script setup lang="ts">
import { ref, provide, readonly } from 'vue'

// Define an InjectionKey for type safety
import { themeKey } from './injection-keys'

const theme = ref<'light' | 'dark'>('light')

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

// provide() makes values available to ALL descendants
// readonly() prevents child components from mutating the ref directly
provide(themeKey, {
  theme: readonly(theme),
  toggleTheme,
})
</script>

<template>
  <div :data-theme="theme">
    <slot />
  </div>
</template>