Skip to content
TutorialAdvanced Vue

Provide and inject, explained with a theme example

A theme ref lives in App.vue. A toggle button lives deep inside the layout. Here is how provide() and inject() connect them, keep the value reactive, and stay typed.

vuejs.school team10 Jun 20255 min readVue 3.5

Your app has a light and a dark theme. The current choice lives at the top, in App.vue, because the whole page needs it. The button that switches it lives further down, in a sidebar inside the page layout.

Vue's provide() and inject() let that button reach the theme directly. You provide the theme once in App.vue. Any component underneath can then read it and change it through one function, fully typed, with no prop passing through the components in between.

The prop-drilling problem

The obvious way to get theme to the button is props. App.vue passes it to AppLayout, which passes it to Sidebar, which passes it to ThemeToggle. When the toggle emits an event, Sidebar and AppLayout each have to re-emit it upwards. Two components declare a prop and an event they never use themselves. Here is Sidebar.vue:

components/Sidebar.vueBreaks
<script setup lang="ts">
import ThemeToggle from './ThemeToggle.vue'

defineProps<{ theme: 'light' | 'dark' }>()
const emit = defineEmits<{ toggle: [] }>()
</script>

<template>
  <aside>
    <nav>…</nav>
    <ThemeToggle :theme="theme" @toggle="emit('toggle')" />
  </aside>
</template>

The Vue docs call this prop drilling: data that has to be carried through components that only hand it on. It works, but every layer you add means two more edits, and refactoring the tree means rewiring all of them.

Provide once, inject anywhere

Vue has a second channel between components. A parent calls provide(key, value). Any component below it in the tree, however deep, calls inject(key) and receives that value. The components in between do nothing.

App.vue
<script setup lang="ts">
import { provide, ref } from 'vue'
import AppLayout from './components/AppLayout.vue'

const theme = ref<'light' | 'dark'>('light')

provide('theme', theme)
</script>

<template>
  <div :class="theme">
    <AppLayout />
  </div>
</template>
components/ThemeToggle.vue
<script setup lang="ts">
import { inject } from 'vue'

const theme = inject('theme')
</script>

<template>
  <button type="button">Theme: {{ theme }}</button>
</template>

Sidebar.vue and AppLayout.vue go back to having no theme prop and no event. Two rules apply:

  • provide() and inject() must be called synchronously during setup. In <script setup>, that means at the top level, not inside a handler or a setTimeout.
  • If two ancestors provide the same key, inject() gets the value from the closest one. That lets one section of the page provide its own theme for everything inside it.

Keep the injected theme reactive

App.vue provides the ref itself, theme, not theme.value. Vue passes a provided ref through as it is, without unwrapping it, so the toggle holds the same ref that App.vue holds. When App.vue changes theme.value, the button re-renders.

This is the version that goes wrong:

App.vueBreaks
provide('theme', theme.value) // provides the string 'light', once

The string is copied at the moment provide() runs. Later changes to the ref never reach the button, which shows light forever.

Let the button change the theme safely

The button also needs to change the theme. The Vue docs recommend keeping changes to provided state inside the provider and handing out a function that makes them. In App.vue, provide an object with a read-only view of the ref and a function that flips it. The template stays as it was:

App.vue
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
import AppLayout from './components/AppLayout.vue'

const theme = ref<'light' | 'dark'>('light')

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}

provide('theme', { theme: readonly(theme), toggleTheme })
</script>

readonly() returns a proxy of the ref that keeps tracking changes but refuses writes. If a descendant tries theme.value = 'dark', Vue logs a warning in development and ignores the assignment. The only sanctioned way to change the theme is toggleTheme(), and it lives next to the state, so there is one place to look when the theme behaves strangely.

ThemeToggle.vue takes both from the injected object and calls toggleTheme on click:

components/ThemeToggle.vue
<script setup lang="ts">
import { inject } from 'vue'

const { theme, toggleTheme } = inject('theme')
</script>

<template>
  <button type="button" @click="toggleTheme">
    Switch to {{ theme === 'light' ? 'dark' : 'light' }}
  </button>
</template>

Destructuring is safe here because theme is a ref, so the reactive connection survives being pulled out of the object.

Type the key with InjectionKey

With a string key, TypeScript cannot know what inject('theme') returns. It gives you unknown, so the destructuring above fails with Property 'theme' does not exist on type 'unknown'. Vue ships InjectionKey<T>, a Symbol type that carries the value type with it. Put the key and its type in keys.ts, next to App.vue, so both sides import the same key:

keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
  theme: Readonly<Ref<'light' | 'dark'>>
  toggleTheme: () => void
}

export const themeKey: InjectionKey<ThemeContext> = Symbol('theme')

A Symbol key also cannot collide with a string key that another library happens to use, which matters as soon as you publish components.

In App.vue, import themeKey from ./keys and pass it in place of the string: provide(themeKey, { theme: readonly(theme), toggleTheme }). TypeScript now checks the value, and an object without toggleTheme fails to compile.

On the other side, inject(themeKey) is typed as ThemeContext | undefined. The undefined is there on purpose, because nothing guarantees that an ancestor provided the key.

For an optional setting, pass a default as the second argument, inject(key, defaultValue), and the undefined goes away. For a theme that must exist, a default would hide a missing provider, so a small composable that throws a clear error is better:

composables/useTheme.ts
import { inject } from 'vue'
import { themeKey, type ThemeContext } from '../keys'

export function useTheme(): ThemeContext {
  const context = inject(themeKey)
  if (!context) {
    throw new Error('useTheme() needs a provider. Call provide(themeKey, …) in an ancestor.')
  }
  return context
}

ThemeToggle.vue now imports useTheme from ../composables/useTheme and calls const { theme, toggleTheme } = useTheme(), and its template does not change. It no longer mentions keys or inject at all. Because useTheme() calls inject(), the setup rule from earlier applies to it too: call it at the top level of <script setup>.

Provide to the whole app with app.provide()

Sometimes no component sits above the one that needs the value, as with App.vue itself, or the value should exist before any component mounts. The application instance has app.provide(), which makes a value available to every component in the app. Plugins use it, because a plugin's install(app) function runs when main.ts calls app.use(), with no component to call provide() from. The same call works directly in main.ts:

main.ts
import { createApp, readonly, ref } from 'vue'
import App from './App.vue'
import { themeKey } from './keys'

const app = createApp(App)
const theme = ref<'light' | 'dark'>('light')

app.provide(themeKey, {
  theme: readonly(theme),
  toggleTheme: () => { theme.value = theme.value === 'light' ? 'dark' : 'light' },
})

app.mount('#app')

Components call useTheme() exactly as before, and App.vue can now call it too, to read the theme for its class. Remove the provide() call from App.vue when you move the theme here. A component that provides the same key wins inside its own subtree, so a leftover provide() in App.vue would hide the app-level theme from every component below it. The same rule still lets one section of the page override the theme for everything inside it.

Choose between provide/inject and a store

The two tools overlap. To pick one, ask where the value belongs.

Use provide and inject for context that belongs to a part of the component tree: a form that its fields read, a tab group that its tabs register with, a theme for one section, the options of a component library. The provider is a component, the value lives exactly as long as that component does, and nothing outside the subtree can see it.

Use a Pinia store for state the whole app owns regardless of which components are mounted: the logged-in user, the cart, cached API data. A store is reachable from router guards and plain functions as well as components, and devtools shows every change to it.

A theme is a borderline case, which is why it makes a good teaching example. If only the component tree reads it, provide it from App.vue. If a router guard or a plain function needs it too, put it in a store and provide nothing; stores are already reachable from everywhere.

Check it stuck3 questions from the Provide and inject quiz.
Practice now
Was this helpful?

Keep reading