Provide and Inject: How to Avoid Prop Drilling in Vue.js
Imagine that in your project you have a "Grandparent" component that needs to send data to a "Grandchild" component. Traditionally, you would have to pass that data through the "Parent" component, even though the latter doesn't use it at all. This phenomenon is known as Prop Drilling, and it's one of the main causes of code becoming complex and difficult to maintain.
Provide and Inject allow an ancestor component to expose data so that any descendant can "catch" it directly, without needing to manually pass it through each level of the hierarchy.
Common Problems with Prop Drilling
- Costly Maintenance: If you decide to rename a
propor modify its structure, you must update each intermediate component, even if they don't consume the data. - Fragility: By depending on multiple levels of nesting, it's easier to break the data chain due to an error in an intermediate component.
- Low Reusability: Intermediate components lose versatility, as they are forced to receive and transmit
propsthat don't belong to their internal logic.
The Native Solution: Provide and Inject
Vue offers us an integrated solution without the need to resort to external state management libraries (like Pinia):
- Provide: The ancestor component defines and "provides" the data to the component tree.
- Inject: Any descendant component, regardless of its depth, "injects" and consumes that data.
Implementation in Vue 3 (Composition API and Options API)
To implement this communication, we follow a key-value structure.
Provider Component (Grandparent):
<script setup>
import { ref, provide } from 'vue'
import Child from './Child.vue'
const appName = ref('TODOvue')
// Provide the data using a unique "key"
provide('app-name-key', appName)
</script>
<template>
<Child />
</template><script>
export default {
provide: {
appName : 'TODOvue'
}
}
</script>
<template>
<Child />
</template>Consumer Component (Grandchild):
<script setup>
import { inject } from 'vue'
// "Catch" the data using the same key defined in the ancestor
const appName = inject('app-name-key')
</script>
<template>
<h1>{{ appName }}</h1>
</template><script>
export default {
inject: ['appName'],
}
</script>
<template>
<h1>{{ appName }}</h1>
</template>The Challenge of Reactivity and Safety
For changes in the data to flow correctly, we must pass a ref or a reactive. If you want to dive deeper into how reactivity works in Vue 3, you can check out our post What is Reactivity? The magic behind Vue.js explained clearly. However, this introduces a risk: a child component could attempt to modify the value directly.
// Danger! Modifying state from a child makes debugging difficult
language.value = 'en'If any component can alter the injected state, we lose track of who, when, and why the data changed. To solve this, we apply the Read-Only Injection pattern:
- The data: It's protected with
readonly()to avoid accidental modifications. - The function: A specific function is provided to make the change, centralizing the logic in the ancestor.
Example of Correct Usage with readonly
Provider Component (Grandparent):
<script setup>
import { ref, provide, readonly } from 'vue'
const language = ref('en')
const changeLanguage = (newLang) => {
language.value = newLang
}
provide('config-language', {
language: readonly(language), // We protect the state
changeLanguage // We expose the modification pathway
})
</script><script>
import { computed } from 'vue'
export default {
data() {
return {
language: 'en'
}
},
methods: {
changeLanguage(newLang) {
this.language = newLang
}
},
provide() {
return {
// IMPORTANT: In Options API, 'provide' is evaluated only once.
// To maintain reactivity, we wrap the value with computed()
// which creates a getter function that Vue reevaluates automatically
'config-language': {
// We use computed so the child sees changes to this.language
language: computed(() => this.language),
changeLanguage: this.changeLanguage
}
}
}
}
</script>Consumer Component (Grandchild):
<script setup>
import { inject } from 'vue'
const { language, changeLanguage } = inject('config-language')
</script>
<template>
<div>
<p>Current language: {{ language }}</p>
<button @click="changeLanguage('es')">Change to Spanish</button>
</div>
</template><script>
export default {
inject: {
configLanguage: {
from: 'config-language',
default: () => ({
language: { value: 'en' },
changeLanguage: () => {}
})
}
},
computed: {
currentLanguage() {
return this.configLanguage.language.value
}
},
methods: {
changeToSpanish() {
this.configLanguage.changeLanguage('es')
}
}
}
</script>
<template>
<div>
<p>Current language: {{ currentLanguage }}</p>
<button @click="changeToSpanish">Change to Spanish</button>
</div>
</template>Handling Default Values
To make your components more robust, you can define a default value in the inject. If the component is used outside of a tree that provides the key, you'll avoid runtime errors:
// If it doesn't find 'user-data', it will use the default object
const user = inject('user-data', { name: 'Guest', premium: false })Provide/Inject at Application Level
In addition to using it between components, you can define global values directly in the application instance. This is useful for:
- Application configuration (API URLs, service keys)
- Global themes or preferences
- Shared plugins or utilities
Implementation:
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// Provide global values available throughout the entire application
app.provide('api-url', 'https://api.example.com')
app.provide('theme', 'dark')
app.provide('analytics', {
trackEvent: (eventName) => {
console.log(`Event: ${eventName}`)
}
})
app.mount('#app')Consuming in any component:
<script setup>
import { inject } from 'vue'
const apiUrl = inject('api-url')
const theme = inject('theme')
const analytics = inject('analytics')
const handleClick = () => {
analytics.trackEvent('button-click')
}
</script>
<template>
<div :class="`theme-${theme}`">
<p>API Base: {{ apiUrl }}</p>
<button @click="handleClick">Click</button>
</div>
</template>Advantages
- You don't need to create a root component just to provide values
- Ideal for configurations that don't change during the application's lifetime
- Simplifies testing by allowing easy override of these values
Note: For reactive values at the application level that change frequently, consider using Pinia instead of
app.provide().
Under the Hood: How Does it Work?
The efficiency of provide/inject lies in its use of JavaScript's prototype chain.
- Provide: When a component provides something, Vue creates a new
providesobject for that component which inherits from its parent'sprovidesobject viaObject.create(). - Inject: Vue simply looks for the key in the object. If it's not in the immediate parent, JavaScript's engine climbs up the prototype chain until it finds it or reaches the end.
This architecture allows a child to "override" a value for its own descendants without affecting its siblings or ancestors, ensuring complete isolation.
Capability Summary
| Feature | Basic Level | Professional Level |
|---|---|---|
| Flow | Pass data from A to B | Hierarchical state architecture |
| Safety | Mutable data (risky) | Use of readonly() to protect state |
| Robustness | Can fail if key is missing | Use of default values |
| Scope | Between local components | Global configuration at app level |
When NOT to Use Provide/Inject
- For complex global state: Use Pinia or Vuex
- For sibling communication: Use composables or the Event Bus pattern
- For simple 2-level state: Props are more explicit and easier to follow
Conclusion
Provide and inject are powerful tools that, when used correctly, eliminate Prop Drilling without compromising code maintainability. The key is to:
- Protect the state with
readonly() - Provide functions to modify it in a controlled way
- Define default values for greater robustness
- Use it at the appropriate level: components for hierarchical state, Pinia for complex global state
Mastering this pattern will allow you to build more scalable and maintainable Vue applications.



