Provide/inject in Vue.js
provide/inject - a mechanism in Vue.js for passing data from a parent component to any descendant without threading it through intermediate components as props.
Theory
TL;DR
- Analogy: like a shared locker at the office - the parent drops something in (provide), any descendant picks it up by key (inject), nobody in between needs to touch it
- Core difference: props require every intermediate component to explicitly accept and forward data; provide/inject skips them entirely
- Reactivity works: if you provide a
ref, descendants get the same ref object, not a copy - changes flow both ways automatically - Decision rule: provide/inject for theme, auth, locale, config; props for direct parent-child communication; Pinia for app-wide state with mutations
Quick example
// App.vue - parent
<script setup>
import { provide, ref } from 'vue'
const user = ref({ name: 'Alice', role: 'admin' })
provide('currentUser', user) // available to ALL descendants
</script>
// DeepChild.vue - could be 10 levels deep, no intermediate props needed
<script setup>
import { inject } from 'vue'
const user = inject('currentUser', ref({ name: 'Guest' })) // second arg = default
console.log(user.value.name) // 'Alice'
</script>The descendant receives the same ref object, not a copy. Change it in one place, every subscriber updates.
Key difference from props
Props create an explicit chain: every component in the tree must declare the prop, accept it, and pass it down. With five levels of nesting that means five components carrying data they do not actually use. provide/inject cuts that chain. The parent declares once; any descendant at any depth injects directly. Intermediate components stay clean.
When to use
- provide/inject: theme switching, authentication state, global config, feature flags, locale, shared services like an API client
- props: direct parent-child data flow, anything that only 1-2 levels need, when you want traceable data flow
- Pinia: app-wide state that needs devtools, time-travel debugging, or complex mutations
How it works internally
Vue maintains a provide chain per component instance. When a component calls provide(key, value), Vue stores the value in that instance. When a descendant calls inject(key), Vue walks up the component tree looking for the nearest ancestor that provided that key. If two ancestors provide the same key, the closer one wins - which lets you override values in subtrees, like a nested theme. If nothing is found, inject returns the default value you passed or undefined.
Application-level provide
You can also provide at the app level, which makes a value available to every component:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.provide('apiUrl', 'https://api.example.com')
app.mount('#app')// AnyComponent.vue
<script setup>
import { inject } from 'vue'
const apiUrl = inject('apiUrl') // 'https://api.example.com'
</script>This is how Vue Router and Vuetify make their internals available globally without explicit imports in every file.
Symbol keys
String keys can clash if two different parts of your codebase use the same name. Symbols fix that:
// keys.js
export const ThemeKey = Symbol('theme')
export const UserKey = Symbol('user')
// Parent.vue
import { ThemeKey, UserKey } from './keys'
provide(ThemeKey, theme)
provide(UserKey, user)
// Child.vue
import { ThemeKey } from './keys'
const theme = inject(ThemeKey) // string typos are impossibleIn TypeScript, pair Symbols with InjectionKey<T> for full type safety:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const UserKey: InjectionKey<Ref<{ name: string }>> = Symbol('user')
// inject(UserKey) now returns Ref<{ name: string }> | undefined automaticallyCommon mistakes
Mistake 1: providing the value instead of the ref
// Wrong - passes a static snapshot, not the reactive ref
const count = ref(0)
provide('count', count.value) // number, not Ref<number>
// Right
provide('count', count)If you pass count.value, the descendant gets 0 as a plain number. Mutations later will not reach it.
Mistake 2: mutating injected data directly
// Works, but not recommended - unclear who owns the data
const config = inject('config')
config.value.apiUrl = 'https://new.api.com'
// Better - provide the update function alongside the data
// In parent:
const updateConfig = (key, value) => { config.value[key] = value }
provide('config', config)
provide('updateConfig', updateConfig)
// In child:
const updateConfig = inject('updateConfig')
updateConfig('apiUrl', 'https://new.api.com') // explicit, traceableMistake 3: forgetting a default value
// If parent never provided 'user', this crashes at runtime
const user = inject('user')
console.log(user.value.name) // TypeError: Cannot read properties of undefined
// Safe version
const user = inject('user', ref({ name: 'Guest' }))Always add a default when the providing component is optional or might not exist in the tree.
Mistake 4: expecting isolation, getting a shared reference
// Parent
const count = ref(0)
provide('count', count)
// Child A
const count = inject('count')
count.value++ // increments the shared ref
// Child B
const count = inject('count')
console.log(count.value) // 1, not 0 - it is the same objectThis is correct behavior, but it surprises developers who expect a copy. Both children share the exact same ref. If you need isolation, provide separate refs.
Mistake 5: circular provide chains
If a child re-provides data it injected from a parent, and a grandchild injects from both, you get hidden coupling. Changing the tree structure later breaks the grandchild without any obvious error. Keep provide/inject chains linear.
Real-world usage
- Vue Router provides route metadata to nested route components - that is how
useRoute()works internally - Vuetify provides theme config to all child components without prop drilling
- Vee-Validate has a parent form that provides validation context to every nested field
- Pinia internally uses provide/inject to make stores available across the app
I reach for provide/inject most often with authentication context - providing the current user and a logout function at the app level so any component can access them without touching props.
Follow-up questions
Q: What happens if two ancestors provide the same key?
A: The closest ancestor wins. Descendants cannot see through it to grandparents. This is intentional - it lets you override values in subtrees, for example providing a different theme inside a modal.
Q: What is the difference between provide/inject and Pinia?
A: provide/inject is scoped to a component subtree. Pinia stores are global singletons with devtools support, time-travel debugging, and structured mutation patterns. Use provide/inject for local shared context; use Pinia when you need cross-cutting app-wide state.
Q: Can you inject without a matching provide?
A: Yes. inject returns undefined by default, or the default value you pass as the second argument. This is why forgetting the default is a common source of runtime errors.
Q: How do you type provide/inject in TypeScript?
A: Use InjectionKey<T> from Vue: const UserKey: InjectionKey<Ref<User>> = Symbol('user'). Then inject(UserKey) automatically returns Ref<User> | undefined. Pass a default to narrow the type to Ref<User>.
Q: (Senior) How would you build a multi-level theme override system with provide/inject?
A: Each theme boundary component provides its own theme key. Child components inject from the nearest ancestor. Since Vue always resolves to the closest provider, nesting a dark-theme component inside a light-theme app just works - the dark-theme component shadows the parent's value for all its own descendants.
Examples
Theme system with provide/inject
// ThemeProvider.vue
<template>
<div :class="`theme-${theme}`">
<button @click="toggleTheme">
Switch to {{ theme === 'dark' ? 'light' : 'dark' }}
</button>
<slot />
</div>
</template>
<script setup>
import { provide, ref } from 'vue'
const theme = ref('dark')
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
// Provide both the state and the action
provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>// DeepButton.vue - 8 levels nested, zero prop drilling
<template>
<button @click="toggleTheme" :class="`btn-${theme}`">
Current: {{ theme }}
</button>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
// Clicking this updates theme globally - all descendants re-render
</script>Providing the function alongside the data keeps mutation logic in the component that owns the state. Any number of descendants can call it without needing to know where the data lives.
Type-safe injection with Symbol keys
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export interface User {
id: number
name: string
role: 'admin' | 'viewer'
}
export const UserKey: InjectionKey<Ref<User>> = Symbol('user')
// Parent.vue
import { provide, ref } from 'vue'
import { UserKey } from './keys'
const user = ref<User>({ id: 1, name: 'Alice', role: 'admin' })
provide(UserKey, user)
// Child.vue - TypeScript knows the exact shape
import { inject } from 'vue'
import { UserKey } from './keys'
const user = inject(UserKey) // Ref<User> | undefined
if (user) {
console.log(user.value.role) // TypeScript confirms 'role' exists
}String keys work but give you unknown on inject. Symbols with InjectionKey<T> give you the full type and catch import mistakes at compile time, before the code ever runs.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.