The Five-Level Hole: Passing Data Past Components That Don't Care
A dark-mode preference lives in App.vue. The component that renders the toggle is there; the components that need the value — a modal three levels down, a table cell five levels down — are nowhere near it. Prop drilling would thread :theme through Layout, Dashboard, PanelList, Panel, and Cell: five components declaring a prop that only the first and last understand, and every intermediate template gaining a line it has no opinion about.
Vue's targeted solution is dependency injection:
<!-- App.vue: the provider -->
<script setup>
import { ref, provide } from 'vue'
const theme = ref('dark')
provide('app-theme', theme) // any descendant may now inject 'app-theme'
function toggle() { theme.value = theme.value === 'dark' ? 'light' : 'dark' }
</script>
<!-- deep descendant, e.g. Cell.vue: the consumer -->
<script setup>
import { inject } from 'vue'
const theme = inject('app-theme') // the SAME ref App.vue provided
</script>
<template>
<td :class="'cell-' + theme">{{ value }}</td>
</template>
provide(key, value) registers a value on the current component's injection context; inject(key) searches the current component's parent chain at runtime and returns the nearest provided value — skipping every intermediate level. Layout, Dashboard, PanelList, and Panel never see the string app-theme; the contract is strictly between App and Cell. When a theme change re-renders the deep cell, it is because the cell injected the ref itself and its render tracks it — the same reactivity rule as everywhere else: provide the container, not the value.
Two safety valves complete the mechanism:
A default for missing providers. inject(key, defaultValue) returns the fallback when no ancestor provided the key:
const theme = inject('app-theme', 'light') // usable even outside the expected tree
Without a default, a missing provider logs a dev warning ([Vue warn] injection 'app-theme' not found.) and yields undefined — which is why production-bound library components should always inject with defaults: they will be mounted in trees you don't control.
App-level provides. app.provide('app-theme', themeRef) before mount() makes the value injectable from any component, including the root — this is how Vue's own plugins (vue-router's useRouter, Pinia's useStore) work, and it is the right home for singletons that genuinely belong to the application rather than one subtree.
The mechanism is small; the design discipline is the real content. Provide/inject bypasses the prop chain's visibility: in the props model, Panel.vue's template shows you every input the panel receives; with injection, reading Panel.vue tells you nothing about what data arrives from above — the value's origin is a search through every provide( call in the app. That trade is worth making for genuinely cross-cutting values — theme, current user, locale strings, toast/modal APIs, feature flags — where every intermediate declaration would be noise. It is a bad trade for ordinary feature data that one parent and one child share, which is what props are for. The next two chapters cover the two details that separate a correct injection contract from a fragile one: keyed, typed constants, and the reactivity rules for what you put on the providing side.
Typed Keys, Single Definitions: How Provide and Inject Stay in Sync
The injection key is a string in every example so far, and that is where the drift begins. 'app-theme' typed as 'app-tHEME' in one component injects nothing — a dev warning at runtime, undefined in the value, and no build error anywhere. Two components providing 'user' with different shapes collide silently, with the nearest provider winning. The fix is a shared, typed key module:
// keys.ts — the single source of the contract
import type { InjectionKey } from 'vue'
export interface ThemeContext {
theme: 'light' | 'dark'
setTheme: (t: 'light' | 'dark') => void
}
export const themeKey: InjectionKey<ThemeContext> = Symbol('app-theme')
// provider
import { themeKey } from './keys'
provide(themeKey, { theme: themeRef, setTheme })
// consumer — type error if the provider shape changes and this call doesn't
const ctx = inject(themeKey)
if (!ctx) throw new Error('themeKey must be provided above') // or inject(themeKey, fallback)
InjectionKey<T> is a Symbol subtype carrying the value type, and it delivers three guarantees a string cannot. No typos: the symbol is one JavaScript value; a misspelling is a compile error, not a runtime miss. No collisions: two libraries can each define Symbol('user') and coexist — symbols are unique by identity, so even identically described keys don't meet. Typed both directions: provide(themeKey, wrongShape) fails to compile, and inject(themeKey) comes back typed, so consumers get autocomplete on ctx.setTheme and the compiler catches contract drift on both ends.
The key module itself is the architectural pattern: one file per bounded context (or one per app) exporting the key and its interface together, imported by providers and consumers alike. The interface is the reviewable artifact — a diff to keys.ts is a diff to the app's injection contract, visible in one place instead of scattered across provide and inject call sites.
Two naming details for string keys you can't avoid (a plugin consuming an untyped external key, a legacy codebase): prefix them with your app or library name ('acme:theme') so a third-party provide can never shadow yours by accident, and treat string keys as untyped boundaries — wrap them in your own typed helper immediately:
const theme = inject('acme-theme', null) as ThemeContext | null
Default values deserve a second look in injection specifically. inject(key, fallback) uses the fallback when no provider exists at all — it does not merge, and it never overrides a provider that passed undefined deliberately. For object-shaped defaults, pass a fresh value per consumer (a factory invoked at inject time is the safe habit, mirroring the prop-default rule from Declaring and Validating Props). And the required pattern — no default, must-have data — is the explicit guard, because Vue will not throw for you:
const user = inject(userKey)
if (!user) throw new Error('userKey missing — check app-level provide')
A loud, immediate failure at the missing edge beats a downstream cannot read property 'name' of undefined in a component that merely consumed the value. With the contract typed and guarded, the last question about what crosses it is whether the value itself stays reactive in the consumer's hands.
Provide Refs, Not Snapshots: Reactivity and the Limits of Injection
Injection follows the reactivity system's one rule with no exceptions: inject returns exactly what was provided — the same reference. That fact decides whether a consumer is live or frozen, and it produces three distinct outcomes:
// provider
const count = ref(0)
provide(countKey, count) // ✅ live: consumers hold the ref
provide(totalKey, count.value) // ❌ snapshot: 0, forever
const state = reactive({ n: 0 })
provide(stateKey, state) // ✅ live: same proxy, mutations tracked everywhere
A consumer holding the injected ref reads count.value — the render tracks it, a provider-side mutation re-renders every consuming component, and the loop closes exactly like a prop update, just without the intermediate declarations. A consumer holding an injected number holds a copy: the provider increments its ref, the injected value stays at the old number, and no warning ever fires. The dev-time version of this trap is diagnosable in one line — 'does the consumer read .value?' — but the production version is a UI that updates in the header and not in the deep modal, which is the inject analogue of the detach bugs from the reactivity topics.
Providing a computed gets you the best of both: derived, cached, and read-only in every consumer at once:
// provider: one derivation, every consumer sees it live
provide(cartTotalKey, computed(() => cart.value.items.reduce((s, i) => s + i.price, 0)))
And when sharing a writable injected value, decide deliberately who may write. Consumers of an injected ref can assign count.value = 5 — same ref, same power as the provider. If the contract is read-only, wrap it at the boundary:
import { readonly } from 'vue'
provide(configKey, readonly(configRef)) // consumers read; writes warn in dev
readonly over the provided ref gives every consumer a read-only view while the provider keeps the writable original — one-way data flow extended across the injection boundary instead of trusted to convention.
The final discipline is knowing when not to inject. Three sharper tools often fit better:
| Situation | Better tool |
|---|---|
| One parent, one child, ordinary feature data | props and events — visible in both templates |
| Logic shared between components, no cross-tree value | a composable (useCart() over a module/store) — no injection context needed |
| The same value consumed at many arbitrary depths, theming/user/i18n/toasts | provide/inject — the designed use case |
Injection's cost is traceability: you cannot read a component and see its inputs, so every key you add should earn it by being genuinely cross-cutting, and every key should be a typed constant with a reactivity-preserving value (ref, reactive, or computed — never a snapshot). Keep the injection surface small — a handful of app-wide keys a new engineer can enumerate from keys.ts — and provide/inject does exactly what the prop-drilling diagrams can't: data that crosses the tree without dragging every level through the paperwork. The last composition tool of this phase hands control the other way — the child supplying data to the parent's markup, in scoped slots.
Quiz
1. A deep component injects a key that no ancestor provided and passes no default. What happens?
2. The provider runs provide(k, count.value) with count a ref. The provider later increments count. What does the consumer see?
3. Why define injection keys as typed Symbols in a shared module instead of string literals?
4. When is provide/inject the right tool versus props or a composable?
Flashcards
Question
How do provide and inject locate each other, and what are the fallbacks?
Click to reveal answer
Answer
provide(key, value) registers on the current component; inject(key) walks the runtime parent chain and returns the nearest provided value, skipping uninvolved levels. inject(key, default) falls back when NO provider exists; missing without a default logs a dev warning and yields undefined (guard with a throw for required data). app.provide makes values injectable from anywhere.
Question
Why use InjectionKey<T> Symbols in a shared keys module?
Click to reveal answer
Answer
Symbol identity kills cross-library collisions and typo'd lookups (compile error, not silent undefined); the InjectionKey<T> generic types both directions — provide(k, wrongShape) and inject(k).prop both fail under vue-tsc. The keys.ts file becomes the enumerable, reviewable injection contract; string keys need app-prefixed names and untyped wrapping.
Question
What stays reactive across provide/inject, and how do you make injected state read-only?
Click to reveal answer
Answer
Whatever reference you provide: a ref, reactive object, or computed stays live in every consumer (inject returns the same object; renders track it). Providing count.value is a frozen primitive — the silent snapshot trap. Wrap with readonly(providedRef) to give consumers a read-only view while the provider keeps the writable original.
Question
When should you NOT use provide/inject?
Click to reveal answer
Answer
Ordinary parent-child feature data (props/events keep inputs visible in both templates) and shared logic with no cross-tree value (a composable needs no injection context). Injection's cost is traceability — a component's own code doesn't reveal its inputs — so reserve it for genuinely cross-cutting values (theme, user, locale, toasts, flags) consumed at arbitrary depths.
Question
How do Vue's own plugins use this mechanism?
Click to reveal answer
Answer
They app.provide() a typed key before mount and expose a composable that injects it: useRouter/useRoute inject router-provided values, Pinia's useStore injects the active store instance. That's why composables like these must be called during setup — inject resolves against the current component's parent chain, which only exists while setup runs.
Revision Notes
Key Takeaways
- 1. provide/inject crosses arbitrary depths through the runtime parent chain, skipping intermediate levels that would otherwise drill the prop
- 2. Missing injections warn in dev and yield undefined — defaults for optional data, explicit throw guards for required contracts
- 3. Injection returns the exact reference provided: refs and computeds stay live everywhere, primitives freeze on the spot
- 4. Typed InjectionKey symbols in a shared module make the contract compile-checked on both sides and collision-proof
- 5. Reserve injection for genuinely cross-cutting values; props keep ordinary data flow visible and composables share logic without hiding inputs
Interview Tips
- • "How do you avoid deep prop drilling?" — provide/inject with typed keys; then show maturity by naming the cost (hidden inputs) and the alternatives you'd still use for parent-child data
- • "Does provide/inject stay reactive?" — only if you provide the container: ref/computed/reactive are live in consumers; count.value is a snapshot — and readonly() for read-only contracts
- • "How do Vue plugins like the router distribute themselves?" — app.provide a key before mount, expose a composable that injects it during setup — explains why useRouter must run in setup
- • "Two libraries both provide 'user' — what happens?" — string keys collide with nearest-wins silence; typed Symbols are unique by identity so they coexist — the interview proof of why keys are symbols
Cheat Sheet
provide / inject
// keys.ts — the contract, one place
export const themeKey: InjectionKey<ThemeContext> = Symbol('app-theme')
// provider
provide(themeKey, { theme: themeRef, setTheme })
// consumer
const ctx = inject(themeKey) ?? throw ... // or inject(themeKey, default)
app.provide(key, value) // app-wide (plugin pattern)
Lookup: nearest provider up the runtime parent chain · skips uninvolved levels · missing → dev warning + undefined (never throws) · defaults apply only when NO provider exists.
Reactivity rule (unchanged): provide the CONTAINER — ref/reactive/computed stay live in every consumer · provide(count.value) = frozen snapshot, silent · readonly(ref) makes consumers read-only while the provider keeps the writable original.
Keys: typed Symbol = typo/shape errors at compile time + collision-proof identity · string keys need app-prefixed names ('acme:theme') and immediate typed wrapping.
Selection discipline:
| Need | Tool |
|---|---|
| parent ↔ child feature data | props + events (visible inputs) |
| shared logic, no cross-tree value | composable / store |
| app-wide value, arbitrary depths (theme, user, i18n, toasts) | provide/inject |
Cost: traceability — a component's code doesn't show injected inputs; keep the key surface small and enumerable from keys.ts.