Provide and inject
Share a value and the function that changes it with deeply nested children, without passing props through every layer.
What you'll learn
When you finish, you can do these.- 1Pass a value down many layers with provide() and inject()
- 2Provide a read-only ref and the function that changes it
- 3Choose between props, provide and a Pinia store
- 4Fix a value that never updates and a missing provider
A page has a light and a dark theme. Its top component, App, keeps the current one, and a button two levels down, ThemeButton, shows it and switches it. Layout, the component in between, does not care about themes. Props carry a value from a parent to its child and events carry a signal back up, so Layout would need a theme prop and a toggle event only to hand them on. Passing values through components that do not use them is called prop drilling.
provide() lets App offer the theme to every component below it, and inject() lets ThemeButton take it, with nothing added to Layout. The Code tab holds that page: three components and keys.ts, a small file that App and ThemeButton both import. By the end you can trace how the theme reaches the button and make Layout read it too. You need one idea from Reactivity in depth: readonly() wraps an object or a ref, Vue's box for a changing value, in a view that can be read but not written.
Provide in an ancestor, inject below
provide(key, value) in a component makes value, labelled with key, available to every component below it, its descendants. inject(key) in any descendant returns it, however many layers sit in between.
Both calls name the same key. It can be a string, like 'theme' in the diagram. The Code tab uses a Symbol instead, a value that is unique by construction, so two libraries can never clash over the name "theme". Typed as an InjectionKey, a type from Vue, it also tells inject() what type comes back.
In the Code tab, that key, themeKey, sits in keys.ts. Next to it are Theme, the type that allows 'light' or 'dark', and ThemeContext, the shape of what App shares: the theme, read-only, and the function that switches it.
export interface ThemeContext {
theme: Readonly<Ref<Theme>>
toggle: () => void
}
export const themeKey: InjectionKey<ThemeContext> = Symbol('theme')
The 'theme' in Symbol('theme') is only a label for Vue's warnings.
App.vue holds the theme in a ref, writes toggle to flip it, and provides both under themeKey, the theme wrapped in readonly():
const theme = ref<Theme>('light')
function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide(themeKey, { theme: readonly(theme), toggle })
Layout.vue mentions neither the theme nor toggle. ThemeButton.vue injects both with one line and uses them like its own:
const { theme, toggle } = inject(themeKey)!
Its button prints theme and calls toggle on a click. The page shows App's line, "App owns the theme: light", and the button, "Theme: light. Switch". Click the button, and both say dark.
inject() returns undefined when no ancestor provided the key, so its TypeScript type includes undefined. The ! after the call says that cannot happen here.
The lookup starts at the parent and goes up to the nearest component that provided the key, so a component never injects what it provides itself: App reads its own theme directly.
Share a ref, keep the writes with the owner
What App provides decides whether the button stays in step and who may change the theme. App provides the ref itself, not the string inside it. Every component that injects it holds the same ref, so when toggle() changes it, ThemeButton and anything else that reads it update together.
Wrapping it in readonly() keeps the data flowing one way. Descendants can read the theme but not set it: a write through the readonly ref logs a warning and changes nothing. To change the theme they call toggle(), so every change happens in App, where you can find it.
Props, provide or a store
The right way to hand a value down depends on how far it travels. One level down, use a prop: it is listed in the child's definition and visible in the parent's template, so anyone reading the code sees the data flow. Provide hides that link: no template shows it, and inject(themeKey) does not say which ancestor provides the theme.
Provide fits a value that a whole subtree, a component and everything below it, needs while the layers in between do not care: a theme, a form handing its state to its fields, a tabs component talking to its panels.
For the whole app, call app.provide(key, value) in main.ts, the file that starts everything, on the app that createApp(App) returns. Plugins, which add a feature to every component, work this way; the next lesson in this track, Custom directives and plugins, shows how.
State that unrelated parts of the app read and change, such as a cart or the signed-in user, belongs in a Pinia store. Pinia is Vue's official store library: a store keeps state in one place outside the components, so it works wherever a component sits.
What goes wrong
Two mistakes are common: a button that never updates, and one with no theme provided above it. The first provides the value inside the ref instead of the ref.
provide(themeKey, { theme: theme.value, toggle })
theme.value is the string 'light' at the moment provide() runs. ThemeButton receives that string and keeps it. Click the button: App's line switches to dark, the page turns dark, and the button still says light. Vue does not warn. Provide the ref, wrapped in readonly().
provide(themeKey, { theme: readonly(theme), toggle })
The button follows every click again.
The second renders an injecting component where no ancestor provides the key, for example ThemeButton on its own in a test.
const { theme, toggle } = inject(themeKey)!
Vue warns injection "Symbol(theme)" not found., then the destructuring throws Cannot destructure property 'theme' of 'inject(...)' as it is undefined. The ! only silenced TypeScript. Give inject() a default for places without a provider.
const { theme, toggle } = inject(themeKey, {
theme: readonly(ref<Theme>('light')),
toggle: () => {},
})
On its own, the button now shows "Theme: light. Switch", and a click does nothing.
What to remember
provide(key, value)makes a value available to every descendant, andinject(key)reads it from the nearest ancestor that provided it, never from the component itself.- Use a
Symboltyped asInjectionKeyfor the key, so it cannot clash andinject()knows the type. - Provide
readonly(state)with a function that changes it, so all writes happen in the owner. - Props for one level, provide for a subtree that shares a value, a Pinia store for state the whole app changes.
- Provide the ref, not
.value, or descendants get a snapshot that never changes. Giveinject()a default when the provider may be missing.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In Layout.vue, import
injectfrom vue andthemeKeyfrom./keys, addconst { theme } = inject(themeKey)!, and change the paragraph to<p>Layout sees {{ theme }} and passes no props.</p>. Click the button: App's line, Layout's line and the button all switch together, since every injector holds the same ref. - In App.vue, provide
{ theme: theme.value, toggle }instead. Click the button: App's line and the background switch, while the button keeps saying light, because it received a string. Putreadonly(theme)back. - In ThemeButton.vue, add
function forceDark() { theme.value = 'dark' }and a second button,<button @click="forceDark">Force dark</button>. Clicking it changes nothing, and the preview showsSet operation on key "value" failed: target is readonly.Change Force dark's@clicktotoggle: it now switches the theme, through App.