Vue provide inject lets an ancestor component hand a value to any descendant without passing it through every component in between. It's the fix for prop drilling, and it's also the API that most often ends up typed as unknown or cast with as. This post types it properly with InjectionKey, wraps it in a helper that fails loudly when the provider is missing, and ends with a short rule for when to use it instead of props or a store. Vue facts are from the vuejs.org guide and API reference for Vue 3.
// in an ancestor's <script setup>
import { provide, ref } from 'vue'
const count = ref(0)
provide('count', count)// in any descendant's <script setup>
import { inject } from 'vue'
const count = inject('count')Three rules from the docs that matter later:
setup(). In <script setup>, that means the top level, not inside a callback or after an await.inject walks up the parent chain and takes the closest provider. A nearer provider shadows one higher up.If nothing provides the key and you gave no default, inject returns undefined and Vue logs a warning.
With a string key, TypeScript has no idea what the value is:
const count = inject('count') // unknown
const typed = inject<Ref<number>>('count') // Ref<number> | undefinedThe generic argument is a promise you make, and nothing checks it against what the provider passes. Rename a key or change a type on one side and you find out at runtime. String keys can also collide: two libraries that both provide('config', ...) will shadow each other.
InjectionKey<T> is a generic type that extends Symbol. Use it as the key and both sides share one type:
// plan-picker/key.ts
import type { InjectionKey, Ref } from 'vue'
export interface PlanPickerContext {
selected: Readonly<Ref<string | null>>
select: (id: string) => void
}
export const planPickerKey: InjectionKey<PlanPickerContext> = Symbol('PlanPicker')Now provide(planPickerKey, value) is a type error if value isn't a PlanPickerContext, and inject(planPickerKey) returns PlanPickerContext | undefined with no generic and no cast. The Symbol also makes the key unique: another library's Symbol('PlanPicker') is a different key. The Vue docs recommend keeping keys in their own file, like this one, so the provider and consumers import the same symbol.
Provide/inject shines in compound components: a parent and children that only make sense together, like a group of selectable plan cards.
<!-- PlanPicker.vue -->
<script setup lang="ts">
import { provide, readonly } from 'vue'
import { planPickerKey } from './key'
const selected = defineModel<string | null>({ default: null })
provide(planPickerKey, {
selected: readonly(selected),
select: (id: string) => {
selected.value = id
},
})
</script>
<template>
<div role="radiogroup" class="row q-col-gutter-md">
<slot />
</div>
</template><!-- PlanOption.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { usePlanPicker } from './use-plan-picker'
const props = defineProps<{ id: string, title: string }>()
const picker = usePlanPicker()
const isSelected = computed(() => picker.selected.value === props.id)
</script>
<template>
<div class="col-12 col-sm-4">
<q-card
flat
bordered
role="radio"
:aria-checked="isSelected"
tabindex="0"
:class="{ 'bg-blue-1': isSelected }"
class="cursor-pointer q-pa-md"
@click="picker.select(props.id)"
@keydown.enter.space.prevent="picker.select(props.id)"
>
<div class="text-subtitle1">{{ title }}</div>
<slot />
</q-card>
</div>
</template><!-- usage -->
<PlanPicker v-model="plan">
<PlanOption id="starter" title="Starter">For side projects</PlanOption>
<PlanOption id="team" title="Team">For small teams</PlanOption>
<PlanOption id="scale" title="Scale">For larger orgs</PlanOption>
</PlanPicker>The options can sit inside any markup the page wants, at any depth, and they still find their picker. Doing that with props would mean passing selected and an onSelect callback to every option by hand.
(A real radio group also needs arrow-key navigation between options. That's left out here to keep the focus on provide/inject.)
inject(planPickerKey) is typed PlanPickerContext | undefined, so every consumer would need a check. Put the check in one place:
// plan-picker/use-plan-picker.ts
import { inject } from 'vue'
import { planPickerKey } from './key'
export function usePlanPicker() {
const context = inject(planPickerKey, null)
if (!context) {
throw new Error('<PlanOption> must be used inside <PlanPicker>')
}
return context
}Passing null as the default does two things: it silences Vue's "injection not found" warning (you're about to throw a better error anyway), and it makes the return type PlanPickerContext | null, which the if narrows to PlanPickerContext. Consumers get a non-optional type and a message that says exactly what's wrong.
Quasar uses the same pattern internally. In 2.35.2, QStepper provides a private symbol and QStep injects it with a fallback, then logs "QStep needs to be a child of QStepper" when the fallback comes back. QForm does the same so fields can register with their form.
The context above provides readonly(selected) plus a select function, rather than the writable model. That follows the Vue docs' advice: keep state changes in the provider, and if descendants need to change something, provide a function that does it.
The benefit shows up when something breaks. If only select() can change the selection, there's one place to put a breakpoint or a guard ("this plan isn't available in your region"). If every option can write to the ref, any of them might be the culprit. readonly() makes that a type error and a dev-mode warning instead of a convention.
inject has three forms:
inject(key) // T | undefined
inject(key, fallback) // T
inject(key, () => createValue(), true) // T, factory called only when neededThe third argument true tells Vue the default is a factory. Without it, a function default is treated as the value itself, which is what you want when the injected thing is a function.
For values the whole app needs (an API client, a feature-flag reader, a formatter), provide them once on the app instead of in a root component:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { apiKey, createApi } from './api'
const app = createApp(App)
app.provide(apiKey, createApi({ baseUrl: '/api' }))
app.mount('#app')That's the hook Vue plugins use, and the same InjectionKey typing applies.
With Vue Test Utils, mount takes a global.provide option, and symbol keys work as computed property names:
import { mount } from '@vue/test-utils'
import { readonly, ref } from 'vue'
import PlanOption from './PlanOption.vue'
import { planPickerKey } from './key'
const selected = ref<string | null>('team')
const wrapper = mount(PlanOption, {
props: { id: 'team', title: 'Team' },
global: {
provide: {
[planPickerKey]: { selected: readonly(selected), select: (id: string) => { selected.value = id } },
},
},
})The option is tested without a real PlanPicker, and the test can assert that select was called with the right id. That's one quiet benefit of injecting an interface rather than importing a module-level store.
| Situation | Use |
|---|---|
| Parent to direct child, one or two values | Props and emits. Explicit, visible in the template. |
| A component family that works as one unit (tabs, pickers, form + fields) | Provide/inject, with a typed key and a useX() helper. |
| Settings for one subtree (a section in compact mode, a nested theme) | Provide/inject. The nearest provider wins, so subtrees can override. |
| State shared by unrelated pages, with devtools and SSR needs | A store. The Pinia vs composables post has the rule for that one. |
The tell for provide/inject is that the value is scoped to a part of the tree. If two copies of the component on one page should have two independent states, a global store is the wrong tool and provide/inject is the right one: each PlanPicker provides its own context, and its options never see the other picker's.
Is provide/inject reactive? It is if you provide something reactive: a ref, a reactive object or a computed. Providing count.value passes a plain number and loses reactivity.
Can I call inject in a composable? Yes, as long as the composable runs synchronously during a component's setup(). Vue 3.3 added hasInjectionContext() for libraries that need to check first.
Why does my injection come back undefined? Usually the provider isn't an ancestor (a sibling or a teleported tree you expect to be connected), or the call happened after an await in setup().
InjectionKey or a string with a generic? InjectionKey. The type is checked on both sides, and symbols can't collide.
Compound components are easier to reason about when the markup underneath is already solid. Browse the QuasarUI Components: 440 Components in 56 Blocks built with Quasar, 100 of them Free, ready to wrap in your own typed PlanPicker-style families.