Black Friday: 50% off all products for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 11, 2026

Vue provide/inject With TypeScript InjectionKey

Vue provide inject, fully typed: InjectionKey, a helper that fails loudly, readonly state, tests with global.provide, and when props or Pinia fit better.
Vue
TypeScript
Composition API
Architecture

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.

Vue provide inject in 30 seconds

// 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:

  • Both calls must run synchronously during 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.
  • A provided ref arrives as the same ref, not unwrapped, so the descendant stays reactive.

If nothing provides the key and you gave no default, inject returns undefined and Vue logs a warning.

The typing problem with string keys

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> | undefined

The 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.

Typed keys with InjectionKey

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.

A compound component example

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.)

A helper that fails loudly

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.

Keep mutations in the provider

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.

Defaults, factories and app-level provide

inject has three forms:

inject(key)                         // T | undefined
inject(key, fallback)               // T
inject(key, () => createValue(), true) // T, factory called only when needed

The 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.

Testing components that inject

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.

When provide/inject beats props or a store

SituationUse
Parent to direct child, one or two valuesProps 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 needsA 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.

Common questions about Vue provide inject

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.

Put it in a real UI

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.