provide/inject: Cross-Level Dependency Injection Lookup Chain and Type Safety
Vue 3's provide/inject mechanism doesn't rely on any "dependency injection container": its lookup chain is the JavaScript prototype chain. When you call inject(key), you're performing a Object.getPrototypeOf() chain traversal — a mechanism so fast it has nearly zero overhead, while naturally implementing "nearest-wins override" semantics without any special code.
Level 1 · What You Need to Know (1–3 Years Experience)
Basic Usage of provide and inject
In a component tree, ancestor components provide data via provide(), and descendant components consume it via inject(), bypassing all intermediate levels. This solves the props drilling problem — no need to pass data through every layer.
<!-- Ancestor.vue -->
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>
<!-- DeepChild.vue -->
<script setup>
import { inject } from 'vue'
// Second parameter is the default value when no ancestor provides the key
const theme = inject('theme', 'light')
</script>
Key point: The theme obtained by inject() is the exact same ref object that the ancestor provided. Modifying this ref's value triggers reactive updates in all injecting components.
The Problem with String Keys
Using strings as keys means no IDE autocomplete and no type inference:
// Dangerous: typos and type mismatches only surface at runtime
provide('user', { name: 'Alice', age: 28 })
const user = inject('user') // type is unknown
InjectionKey: Type Safety with Symbol + Generics
InjectionKey<T> is Vue's generic interface — essentially Symbol & { _type: T }. Using TypeScript's structural type system, it lets a Symbol carry type information:
// keys.ts — centralize all injection keys
import type { InjectionKey, Ref } from 'vue'
interface User {
id: number
name: string
role: 'admin' | 'editor' | 'viewer'
}
export const userKey: InjectionKey<Ref<User>> = Symbol('user')
export const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
// Provider side
import { provide, ref } from 'vue'
import { userKey } from './keys'
const user = ref<User>({ id: 1, name: 'Alice', role: 'admin' })
provide(userKey, user) // compiler enforces: must be Ref<User>
// Consumer side
import { inject } from 'vue'
import { userKey } from './keys'
const user = inject(userKey) // inferred as Ref<User> | undefined
const user2 = inject(userKey, ref({ id: 0, name: '', role: 'viewer' as const }))
// user2 is Ref<User> (undefined eliminated by default value)
Reactive provide: Pass References, Not Values
When providing reactive objects, the injecting side receives the same reactive reference — mutations propagate automatically:
// Correct: provide the ref itself
const count = ref(0)
provide('count', count)
// Wrong: provide .value — injects a plain number, loses reactivity
provide('count', count.value) // static 0, never updates
Application-level provide
app.provide() makes data available to all components in the application — equivalent to global injection:
// main.ts
const app = createApp(App)
app.provide('globalConfig', {
apiUrl: 'https://api.example.com',
version: '2.1.0'
})
This is how third-party libraries (vue-i18n, vue-router) inject their capabilities into components.
Level 2 · How It Actually Works (3–5 Years Experience)
The Internal Implementation of provide(): Prototype Chain Inheritance
Vue 3's provide implementation is only a few lines. The core is Object.create(). From packages/runtime-core/src/apiInject.ts:
// packages/runtime-core/src/apiInject.ts (simplified)
export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
if (!currentInstance) {
if (__DEV__) {
warn(`provide() can only be used inside setup().`)
}
} else {
let provides = currentInstance.provides
// Critical: if the current instance's provides is the same object as
// the parent's (initial state), create a new object with parent as prototype
const parentProvides = currentInstance.parent?.provides
if (parentProvides === provides) {
provides = currentInstance.provides = Object.create(parentProvides)
}
// Write to the new object (doesn't affect parent provides)
provides[key as string] = value
}
}
The key insight: Object.create(parentProvides) makes the new object's [[Prototype]] point to the parent's provides. This means:
- When reading a key not found on the current object, the engine automatically walks up the prototype chain
- Writing a key to the current object never modifies the parent object (nearest-wins override)
Prototype Chain Structure
app.context.provides (root, prototype chain terminus)
{ globalKey: globalValue }
▲ [[Prototype]]
ComponentA.provides
{ themeKey: 'dark', userKey: userRef }
▲ [[Prototype]]
ComponentB.provides
{ themeKey: 'light' } ← overrides ComponentA's themeKey
▲ [[Prototype]]
ComponentC.provides
(same object as ComponentB.provides — hasn't called provide() yet)
When ComponentC calls inject('themeKey'):
- Check ComponentC.provides: not found
- Walk prototype chain to ComponentB.provides: found
'light', return it - ComponentB's override takes effect; ComponentA's
'dark'is shadowed
The inject() Lookup Process
// packages/runtime-core/src/apiInject.ts (simplified)
export function inject<T>(
key: InjectionKey<T> | string,
defaultValue?: unknown,
treatDefaultAsFactory?: boolean,
) {
const instance = currentInstance || currentApp
if (instance) {
// Start lookup from parent's provides (not self, to avoid cycles)
const provides =
instance === currentApp
? instance.context.provides
: instance.parent == null
? instance.appContext.provides // root component
: instance.parent.provides // normal case
if (provides && (key as string | symbol) in provides) {
// `in` operator traverses prototype chain
return provides[key as string]
} else if (arguments.length > 1) {
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance.proxy)
: defaultValue
}
}
}
The in operator is the core of prototype chain lookup: it doesn't just check own properties — it walks the entire prototype chain upward.
inject Lookup Flow Diagram
inject('themeKey') called
│
▼
Determine provides start point
Is current instance root? → appContext.provides
Otherwise → instance.parent.provides
│
▼
'themeKey' in provides? ← `in` walks prototype chain
├── YES → return provides['themeKey']
└── NO → has default value?
├── YES → return default value
└── NO → return undefined (+ DEV warning)
Why Start from parent.provides, Not instance.provides?
If lookup started from the component's own provides, a component that provided a key could also inject it back — violating the parent-to-child data flow semantics. Starting from parent.provides ensures only descendants can inject what ancestors provide.
Reactive Propagation Path
Ancestor: provide('user', userRef)
userRef = ref({ name: 'Alice' })
provides['user'] = userRef ← stores the ref object itself
Descendant: const user = inject('user')
user === userRef ← same reference
// Mutate userRef.value
userRef.value.name = 'Bob'
→ triggers userRef's dep notification
→ all effects depending on user.value.name re-execute
→ all components that injected user re-render
Important: Reactivity doesn't come from provide/inject itself — it comes from Vue's reactive system tracking ref/reactive objects. provide/inject only passes references.
Preventing Mutation by Injectors: readonly Wrapping
To let injectors read data but not directly mutate it:
// Ancestor.vue
import { provide, ref, readonly } from 'vue'
const user = ref<User>({ id: 1, name: 'Alice', role: 'admin' })
// Provide read-only data + a mutation method (with optional permission checks)
provide(userKey, readonly(user))
provide(updateUserKey, (updates: Partial<User>) => {
Object.assign(user.value, updates)
})
// Descendant.vue
const user = inject(userKey)!
const updateUser = inject(updateUserKey)!
// user.value.name = 'Bob' ← runtime warning: readonly!
updateUser({ name: 'Bob' }) // correct approach
Level 3 · Design Documents and Source Code (Senior Developers)
The provides Field in ComponentInternalInstance
Every component instance is defined in packages/runtime-core/src/component.ts. The provides field is initialized as:
// packages/runtime-core/src/component.ts
export function createComponentInstance(
vnode: VNode,
parent: ComponentInternalInstance | null,
suspense: SuspenseBoundary | null,
) {
const instance: ComponentInternalInstance = {
// ...other fields
provides: parent ? parent.provides : Object.create(appContext.provides),
// ↑ Critical initialization:
// - Has parent: shares the parent's provides object (same reference!)
// - Root component: creates new object with appContext.provides as prototype
}
return instance
}
Sharing the parent's provides object (not copying) means:
- If a component never calls
provide(), itsprovidesIS the parent's same object - Only when
provide()is called doesObject.create(parentProvides)split off a new object
This lazy-split strategy saves memory — most components provide nothing and don't need a separate object.
How InjectionKey Achieves Type Safety
// From Vue's type definitions
export interface InjectionKey<T> extends Symbol {}
This interface extends Symbol but carries generic parameter T. TypeScript's structural type system tracks this generic:
// provide's type signature
function provide<T, K = InjectionKey<T> | string | number>(
key: K,
value: K extends InjectionKey<infer V> ? V : T
): void
// inject's overloaded type signatures
function inject<T>(key: InjectionKey<T> | string): T | undefined
function inject<T>(key: InjectionKey<T> | string, defaultValue: T): T
function inject<T>(
key: InjectionKey<T> | string,
defaultValue: T | (() => T),
treatDefaultAsFactory: true
): T
When you use InjectionKey<User> as a key, TypeScript infers from the generic parameter that provide must receive a User, and inject returns User | undefined.
app.provide() Implementation
// packages/runtime-core/src/apiCreateApp.ts
const app = {
provide(key, value) {
if (__DEV__ && (key as string | symbol) in context.provides) {
warn(
`App already provides property with key "${String(key)}". ` +
`It will be overwritten with the new value.`,
)
}
context.provides[key as string] = value
return app
},
}
app.provide() directly writes to appContext.provides — the terminus of all component provides chains. The root component's provides has appContext.provides as prototype, so all components can access application-level injections through the chain.
effectScope and provide/inject
When using provide/inject inside composables, be aware of effectScope boundaries. If a composable creates a detached effectScope, its effects are decoupled from component lifecycle:
// Advanced pattern: manage reactive state in effectScope, share via provide
function createSharedStore<T>(factory: () => T) {
let store: T
let scope: EffectScope
return {
install(app: App) {
scope = effectScope(true) // detached scope
store = scope.run(factory)!
app.provide(storeKey, store)
},
dispose() {
scope.stop()
}
}
}
This is Pinia's core idea: use effectScope to manage store reactive lifecycles, distribute store references via provide/inject.
Suspense Interaction
// packages/runtime-core/src/apiInject.ts
export function inject<T>(key: InjectionKey<T> | string, ...args: any[]) {
const instance = currentInstance || currentRenderingInstance
// currentRenderingInstance handles the case where inject() is called
// during render (e.g., in a functional component's render function)
// rather than in setup()
}
Inside <Suspense>, async components that are resolving may have currentInstance set to the Suspense boundary itself. Vue handles this by also checking currentRenderingInstance, ensuring inject works correctly during async resolution.
Performance Characteristics
Prototype chain lookup is O(depth) where depth is the component tree depth. For typical applications (depth < 20), this overhead is negligible — modern JavaScript engines heavily optimize prototype chain traversal.
By comparison, Pinia/Vuex use a global store object — O(1) hash table lookup. But provide/inject's memory model is lighter — no centralized storage required. The prototype chain structure maps directly onto the component hierarchy, making it architecturally appropriate for component-scoped dependency sharing.
Level 4 · Edge Cases and Pitfalls (All Levels)
Pitfall 1: Calling inject() in Async Context
inject() depends on currentInstance, which is only valid during the synchronous portion of a component's setup() execution:
// ❌ Wrong: currentInstance is null after await
const setup = async () => {
await fetchData()
const user = inject(userKey) // Warning: inject() can only be used inside setup()
// user is undefined
}
// ✅ Correct: call inject() before any await
const setup = async () => {
const user = inject(userKey) // currentInstance is valid here
await fetchData()
// use user...
}
Root cause: Vue's reactivity system uses a global variable to track "the component currently running setup." async/await breaks the synchronous call stack, resetting currentInstance to null when setup suspends.
Pitfall 2: Providing a Non-Reactive Value and Expecting Reactivity
// ❌ Wrong: providing a plain object; later mutations don't trigger updates
const config = { theme: 'dark' }
provide('config', config)
config.theme = 'light' // injectors won't update — config is not reactive
// ✅ Correct: provide a reactive object
const config = reactive({ theme: 'dark' })
provide('config', config)
config.theme = 'light' // injectors reactively update
Pitfall 3: The Default Value Factory Function's Third Parameter
When a default value is a function, the third parameter treatDefaultAsFactory: true invokes it as a factory. Without it, the function itself is returned as the default:
// Without treatDefaultAsFactory, the function IS the default value
const user = inject(userKey, () => defaultUser)
// user is the function itself, not defaultUser!
// With treatDefaultAsFactory: true, the function is called
const user = inject(
userKey,
() => ({ id: 0, name: 'Guest', role: 'viewer' as const }),
true
)
// user is { id: 0, name: 'Guest', role: 'viewer' }
This is an easy API to misuse — forgetting the third parameter when using a factory function is a silent bug.
Pitfall 4: Prototype Chain Override is Tree-Scoped, Not Global
provide's "nearest-wins" only affects the providing component and its descendants — not siblings:
Root: provide('key', 'root-value')
ComponentA: provide('key', 'a-value') ← override
ComponentA1: inject('key') → 'a-value' ✓
ComponentA2: inject('key') → 'a-value' ✓
ComponentB: (no provide)
ComponentB1: inject('key') → 'root-value' ✓ (unaffected by A's override)
Developers sometimes assume that providing in one branch affects all descendants globally — it doesn't.
Pitfall 5: Duplicate Key Warning on app.provide()
// These are fine — component provide() overrides app.provide() silently
app.provide('theme', 'light')
// Later in a component:
provide('theme', 'dark') // OK, nearest-wins
// But calling app.provide() twice with same key triggers a DEV warning:
app.provide('theme', 'light')
app.provide('theme', 'dark') // ⚠️ DEV warning: App already provides property...
Pitfall 6: inject() in Components Outside the App Tree
Components rendered outside the Vue app tree (e.g., via document.createElement + manual mount) have no appContext and cannot access injected values. This affects server-side rendering scenarios where component instances are created manually.
Decision Tree: provide/inject vs Pinia vs Props
Do you need to share state?
│
├─ Shared across the entire application (user info, global config)
│ └─ → Pinia (centralized, devtools, time-travel debugging)
│
├─ Shared within a component subtree (form context, theme, layout)
│ ├─ Needs to cross 3+ levels
│ │ └─ → provide/inject (designed exactly for this)
│ └─ Only 1-2 levels deep
│ └─ → props (explicit, traceable)
│
└─ State belongs to a single component
└─ → ref/reactive (local state)
UI component libraries (Element Plus, Naive UI, Ant Design Vue) make heavy use of provide/inject for parent-child component communication in Form/FormItem, Table/Column, Select/Option relationships. This is provide/inject's primary use case in production code.
Chapter Summary
-
The prototype chain is the lookup chain:
provide()callsObject.create(parent.provides)to create a new object;inject()uses theinoperator to walk the prototype chain. "Nearest-wins override" is a natural consequence of JavaScript prototype inheritance — no special implementation required. -
Lazy splitting conserves memory: Component instances initially share their parent's
providesreference directly. Only whenprovide()is first called does a new object split off. Components that never provide anything pay zero memory overhead. -
Reactivity comes from reference passing:
provide/injectdoes not provide reactivity — reactivity comes from theref/reactiveobjects being passed. Providing a plain value cannot trigger reactive updates in injectors. -
InjectionKey<T>is a compile-time contract: Through Symbol + TypeScript generics, type constraints are established on both the provide and inject sides, moving type errors from runtime to compile time. This is standard practice for production code. -
readonlyprotects unidirectional data flow: Providingreadonly(state)along with exposed mutation methods ensures injectors cannot directly mutate state, preserving the ancestor's ownership of data — the recommended pattern for large-scale applications using provide/inject.