Vue 3’s provide / inject is dependency injection for your component tree: an ancestor provides a value, and any descendant can inject it—without prop-drilling.
This article goes beyond the basics and shows how to build a typed, reactive, safe “AppContext” you can use in real projects (theme, API client, notifications, and local overrides).
The mental model (what provide/inject really is)
-
provide(key, value)attaches a value to the current component instance as a context entry -
inject(key)walks up the parent chain and returns the closest provided value for that key - If you re-
providethe same key in a subtree, it overrides the value for that subtree
It’s not a store.
It’s best used for context (design system settings, composed components like Form/Field, or shared services like an API client).
Reactivity: the most common pitfall
provide does not magically make things reactive.
✅ Reactive (share a ref / reactive object):
provide(ThemeKey, ref("dark"))
provide(UserKey, reactive({ name: "Vincent" }))
⚠️ Not reactive (primitive snapshot):
let theme = "dark"
provide(ThemeKey, theme) // descendants won't see future changes
If you want descendants to react to updates, provide the reactive container (ref, reactive, computed)—not just the raw primitive value.
Why TypeScript matters here: use InjectionKey
Using plain strings as keys is fragile. The recommended approach is a typed InjectionKey:
import type { InjectionKey } from "vue";
export type ThemeContext = { /* ... */ };
export const ThemeKey: InjectionKey<ThemeContext> = Symbol("Theme");
This gives you:
- collision-free keys (
Symbol) - full type inference in
inject
The real-world example: a typed AppContext
Imagine a dashboard-like app with deep trees of components:
- many nested widgets
- a consistent theme
- an API client configured once (baseURL, auth token, language)
- a toast/notification service available everywhere
You could pass all of that via props, but it becomes painful fast.
Instead, we’ll build:
-
AppContext(a single object containing these services) - a provider component (
<AppProvider>) - a safe composable (
useApp())
1) Define the context types + keys
Create src/context/app.context.ts:
import type { InjectionKey, Ref } from "vue";
export type ApiClient = {
get<T>(url: string, init?: RequestInit): Promise<T>;
post<T>(url: string, body: unknown, init?: RequestInit): Promise<T>;
};
export type ToastService = {
success(message: string): void;
error(message: string): void;
};
export type AppContext = {
theme: Ref<"light" | "dark">;
toggleTheme(): void;
api: ApiClient;
toast: ToastService;
locale: Ref<string>;
};
export const AppKey: InjectionKey<AppContext> = Symbol("AppContext");
2) Build an API client factory (dependency injected)
Create src/context/api.ts:
import type { ApiClient } from "./app.context";
type CreateApiOptions = {
baseUrl: string;
token: () => string | null; // getter so it always reads the latest token
locale: () => string; // getter for Accept-Language, etc.
};
export function createApiClient(opts: CreateApiOptions): ApiClient {
async function request<T>(
method: string,
url: string,
body?: unknown,
init?: RequestInit
): Promise<T> {
const headers = new Headers(init?.headers);
headers.set("Accept", "application/json");
headers.set("Content-Type", "application/json");
const t = opts.token();
if (t) headers.set("Authorization", `Bearer ${t}`);
const lang = opts.locale();
if (lang) headers.set("Accept-Language", lang);
const res = await fetch(`${opts.baseUrl}${url}`, {
...init,
method,
headers,
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
// You can expand this with better error objects (status, payload, etc.)
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
}
return (await res.json()) as T;
}
return {
get: (url, init) => request("GET", url, undefined, init),
post: (url, body, init) => request("POST", url, body, init),
};
}
Key idea: token() and locale() are functions. Even if token/locale changes later, the API client always reads the latest value.
3) Provide the context in a dedicated provider component
Create src/context/AppProvider.vue:
<script setup lang="ts">
import { provide, ref } from "vue";
import { AppKey, type AppContext } from "./app.context";
import { createApiClient } from "./api";
/**
* In a real app, token might come from cookies, localStorage, a Pinia auth store, etc.
* We'll keep it simple and store it in a ref.
*/
const token = ref<string | null>(null);
const locale = ref("en");
const theme = ref<"light" | "dark">("light");
function toggleTheme() {
theme.value = theme.value === "light" ? "dark" : "light";
}
/**
* Minimal toast service for the demo.
* Replace with your own UI toast system (Sonner, Vue Toastification, etc.)
*/
const toast = {
success(message: string) {
console.log("✅", message);
},
error(message: string) {
console.error("❌", message);
},
};
const api = createApiClient({
baseUrl: "https://example.com/api",
token: () => token.value,
locale: () => locale.value,
});
const ctx: AppContext = {
theme,
toggleTheme,
api,
toast,
locale,
};
provide(AppKey, ctx);
</script>
<template>
<!-- Anything inside can inject AppKey -->
<div :data-theme="theme">
<slot />
</div>
</template>
4) Create a safe useApp() composable
import { inject } from "vue";
import { AppKey, type AppContext } from "./app.context";
export function useApp(): AppContext {
const ctx = inject(AppKey);
if (!ctx) throw new Error("useApp() must be used within <AppProvider>.");
return ctx;
}
This avoids repeating:
inject(...)- null checks
- inconsistent error handling
Consume the context (deep component)
Create src/components/ThemeToggle.vue:
<script setup lang="ts">
import { computed } from "vue";
import { useApp } from "../context/useApp";
const { theme, toggleTheme } = useApp();
const label = computed(() => (theme.value === "dark" ? "Switch to light" : "Switch to dark"));
</script>
<template>
<button @click="toggleTheme()">{{ label }}</button>
</template>
Create src/components/UserWidget.vue:
<script setup lang="ts">
import { ref } from "vue";
import { useApp } from "../context/useApp";
type User = { id: number; name: string };
const { api, toast } = useApp();
const user = ref<User | null>(null);
const loading = ref(false);
async function loadUser() {
loading.value = true;
try {
user.value = await api.get<User>("/me");
toast.success("User loaded");
} catch (e) {
toast.error((e as Error).message);
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<button :disabled="loading" @click="loadUser">Load me</button>
<pre v-if="user">{{ user }}</pre>
</div>
</template>
Finally, wrap your app:
<!-- App.vue -->
<script setup lang="ts">
import AppProvider from "./context/AppProvider.vue";
import ThemeToggle from "./components/ThemeToggle.vue";
import UserWidget from "./components/UserWidget.vue";
</script>
<template>
<AppProvider>
<ThemeToggle />
<UserWidget />
</AppProvider>
</template>
Local overrides: provide a different locale for a subtree
A powerful feature of provide is contextual override.
Create src/context/LocaleOverride.vue:
<script setup lang="ts">
import { provide, ref, watch } from "vue";
import { useApp } from "./useApp";
import { AppKey, type AppContext } from "./app.context";
/**
* We clone the existing context but override only what we need (locale).
* Important: we re-provide AppKey so descendants see the overridden context.
*/
const props = defineProps<{ locale: string }>();
const parent = useApp();
const overriddenLocale = ref(props.locale);
watch(() => props.locale, (v) => (overriddenLocale.value = v));
const ctx: AppContext = {
...parent,
locale: overriddenLocale,
};
provide(AppKey, ctx);
</script>
<template>
<slot />
</template>
Usage:
<AppProvider>
<UserWidget />
<LocaleOverride locale="fr">
<UserWidget />
</LocaleOverride>
</AppProvider>
Now everything inside <LocaleOverride> sees the overridden locale.
Composed components: Form / Field is a perfect match
provide/inject shines for “compound components”:
-
<Form>provides methods (register,setValue,errors) -
<Field>injects and registers itself - no prop drilling
That’s how many UI libraries implement Tabs, Accordion, Menu, Tooltip, etc.
Pitfalls and best practices
1) Don’t provide mutable state without constraints
If you share a big reactive({ ... }) object, every consumer can mutate it.
Safer patterns:
- provide methods + readonly state
- use
readonly()/shallowReadonly()for state that should not be mutated directly
Example:
import { readonly } from "vue";
provide(Key, { state: readonly(state), setX })
2) Slot scope surprise
Slot content is evaluated in the parent’s scope. Sometimes you’ll expect an injected value from the slot “host”, but it won’t behave the way you think.
If you build advanced wrappers, test injection behavior with slots carefully.
3) SSR note
provide/inject is per app instance (per request in SSR). That’s good.
Just avoid global singletons that leak between requests.
When to use provide/inject vs Pinia vs props
Use provide/inject when:
- it’s a context for a subtree (theme, density, section locale)
- you’re building compound components (Form/Field, Tabs/Tab)
- you need shared services configured at the root of a subtree (API, logger)
Use Pinia when:
- state is global and feature-level
- you want devtools history, persistence, cross-route state
Use props when:
- the relationship is strictly parent → child
- you want explicit component APIs
Summary
-
provide/injectis dependency injection for Vue’s component tree - provide reactive containers (
ref,reactive) for reactivity - use typed
InjectionKeyand a safe composable (useApp()) - override context locally by re-providing the same key in a subtree
Top comments (0)