frontend / vue / 09_provide_inject.md

provide / inject — Dependency Injection

4 min read source

provide / inject — Dependency Injection

TL;DR

provide makes a value available to all descendants of a component; inject reads it. The Vue equivalent of React’s Context — same use case (avoiding prop drilling), same caveats (overuse turns the tree into a global). Senior topics: reactivity of provided values, typed injection keys (InjectionKey<T>), default values, plugin/library patterns, and how Pinia subsumes a lot of what people previously did with provide/inject.

Interview Q&A

Q: Basic example.

A:

<!-- Ancestor.vue -->
<script setup>
import { provide, ref } from "vue";

const theme = ref("dark");
provide("theme", theme);
</script>

<!-- Descendant (any depth) -->
<script setup>
import { inject } from "vue";

const theme = inject("theme");   // Ref<string> | undefined
console.log(theme?.value);
</script>

The key is a string; the value is anything (often a ref so descendants react to changes). No props pass through intermediate components.

Q: Typed injection — InjectionKey<T>.

A: Use a Symbol typed as InjectionKey<T> so TS narrows the inject’s return type.

// keys.ts
import type { InjectionKey, Ref } from "vue";

export const ThemeKey: InjectionKey<Ref<"light" | "dark">> = Symbol("theme");
// Ancestor
provide(ThemeKey, theme);

// Descendant
const theme = inject(ThemeKey);   // Ref<"light" | "dark"> | undefined

Symbols also avoid string-key collisions. Use them for any library/shared-app provide.

Q: Default values for inject.

A:

const theme = inject(ThemeKey, ref("light"));                 // Default if not provided
const config = inject("config", () => createDefault(), true);  // Factory (third arg = treat as factory)

The factory form runs only when the inject misses, useful for expensive defaults.

Q: Reactive provide — make a value updateable from descendants?

A: Provide a ref + an updater function:

// Ancestor
const theme = ref("dark");
const toggleTheme = () => { theme.value = theme.value === "dark" ? "light" : "dark"; };
provide(ThemeKey, { theme, toggleTheme });

// Descendant
const { theme, toggleTheme } = inject(ThemeKey)!;

Or expose a readonly(theme) to enforce one-way reads + the updater being the only mutation path:

provide(ThemeKey, { theme: readonly(theme), toggleTheme });

Q: App-level provide.

A: app.provide(key, value) makes a value available everywhere without a Vue ancestor:

import { createApp } from "vue";

const app = createApp(App);
app.provide("api", new ApiClient());
app.mount("#app");

Plugins use this pattern — app.use(MyPlugin) typically calls app.provide() internally for tokens consumers later inject.

Q: When provide/inject vs Pinia vs props?

A:

Use when
Props parent → direct child; shape matters as a contract
provide/inject ancestor → deep descendant, dependency-style (theme, locale, API client, auth user)
Pinia shared state across many unrelated components, with a centralized API

Rough heuristics:

  • A theme, a router context, an API client, a localization function, a form contextprovide/inject.
  • A logged-in user store, a cart store, a list of notifications, a chat connection → Pinia (state with actions, dev tools, persistence).
  • One-off data → props.

Avoid using provide/inject as a poor man’s global state — Pinia is built for that and gives you dev tools, hot module reload, and explicit boundaries.

Q: Plugin pattern with provide.

A:

// plugin.ts
import type { App, InjectionKey } from "vue";

export const ApiKey: InjectionKey<ApiClient> = Symbol("api");

export const apiPlugin = {
  install(app: App, options: { baseUrl: string }) {
    const client = new ApiClient(options.baseUrl);
    app.provide(ApiKey, client);
  },
};

// main.ts
app.use(apiPlugin, { baseUrl: "/api" });

// any component
const api = inject(ApiKey)!;
const users = await api.getUsers();

This is how Vue Router, i18n, Apollo Client, and others expose themselves — app.use(...) wraps app.provide.

Q: How does this compare to React Context?

A:

Vue React
Declare provide(key, value) createContext(default)
Subscribe inject(key) useContext(Ctx)
Provider component implicit (any ancestor) explicit <Ctx.Provider value=...>
Re-render on change only consumers that read it (refs auto-track) every consumer of the context
Typing InjectionKey<T> Context<T>

Vue’s “any ancestor can provide” is more flexible; React’s explicit Provider is more localized. Both can lead to “magic dependency” smell if overused.

Gotchas / edge cases

  • inject returns undefined if no ancestor provided. Always handle (! if you guarantee it, or use a default).
  • String keys can collide across libraries — always prefer Symbol/InjectionKey for shared code.
  • provide is per-component-tree — if you mount two apps, each has its own provide tree.
  • Non-reactive providers — providing a plain object means descendants won’t react to mutations. Wrap in ref/reactive if you want reactivity.
  • provide inside setup only — can’t call from mounted or async callbacks (same hook rules).
  • Testing components that inject — you need to mount with a parent that provides, or use global.provide in @vue/test-utils.
  • Tree-shakingapp.provide always runs at boot; large API clients should be lazy-instantiated.

What a senior is expected to say

  • provide/inject is Vue’s Context — same use case (avoiding prop drilling for cross-cutting concerns), same caveat (overuse turns deps into magic).”
  • InjectionKey<T> (typed Symbol) for any cross-file or library provide — avoids string collisions and gives TS narrowing.”
  • “Provide refs (or wrapped objects) for reactivity; provide an exposed updater for write access, often with readonly(ref) for read-only exposure.”
  • “Plugin pattern is app.use(plugin) calling app.provide — that’s how Vue Router and i18n expose themselves.”
  • “For shared state with actions and devtools, Pinia is the right tool, not provide/inject. Provide/inject for dependencies (theme, API client, locale).”

Cross-references

Further reading