DEV Community

A0mineTV
A0mineTV

Posted on

Vue 3 provide/inject in depth: build a typed AppContext

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-provide the 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" }))
Enter fullscreen mode Exit fullscreen mode

⚠️ Not reactive (primitive snapshot):

let theme = "dark"
provide(ThemeKey, theme) // descendants won't see future changes
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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),
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Usage:

<AppProvider>
  <UserWidget />

  <LocaleOverride locale="fr">
    <UserWidget />
  </LocaleOverride>
</AppProvider>
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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/inject is dependency injection for Vue’s component tree
  • provide reactive containers (ref, reactive) for reactivity
  • use typed InjectionKey and a safe composable (useApp())
  • override context locally by re-providing the same key in a subtree

Top comments (0)