Frontend / Vue / 09_provide_inject.md

provide / inject — Dependency Injection

Updated 5 interview angles 5 min read source
On this page12
  • Basic example.
  • Typed injection — InjectionKey.
  • Default values for inject.
  • Reactive provide — make a value updateable from descendants?
  • App-level provide.
  • When provide/inject vs Pinia vs props?
  • Plugin pattern with provide.
  • How does this compare to React Context?
  • Gotchas
  • Related
  • Further reading
  • Interview angle
  • provide / inject — Dependency Injection

    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.

    Basic example.

    vue
    <!-- 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.

    Typed injection — InjectionKey<T>.

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

    ts
    // keys.ts
    import type { InjectionKey, Ref } from "vue";
    
    export const ThemeKey: InjectionKey<Ref<"light" | "dark">> = Symbol("theme");
    ts
    // Ancestor
    provide(ThemeKey, theme);
    
    // Descendant
    // Ref<"light" | "dark"> | undefined
    const theme = inject(ThemeKey);

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

    Default values for inject.

    ts
    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.

    Reactive provide — make a value updateable from descendants?

    Provide a ref + an updater function:

    ts
    // 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:

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

    App-level provide.

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

    ts
    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.

    When provide/inject vs Pinia vs props?

    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 context → provide/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.

    Plugin pattern with provide.

    ts
    // 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.

    How does this compare to React Context?

    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

    • 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-shaking — app.provide always runs at boot; large API clients should be lazy-instantiated.

    Further reading

    Interview angle 5

    • “What is provide/inject for?” - the same job as React Context: getting a cross-cutting dependency to a deep descendant without threading props through every level. And the same caveat - overuse turns explicit dependencies into action at a distance.
    • “How do you make it type-safe?” - an InjectionKey<T>, which is a typed Symbol. It avoids string-key collisions between libraries and gives you the value’s type at the injection site instead of unknown.
    • “Is what you provide reactive?” - only if you provide something reactive. Providing someRef.value hands over a snapshot; provide the ref itself. Expose it as readonly(ref) plus a separate updater function when consumers should read but not write.
    • “How do Vue plugins use this?” - app.use(plugin) calls app.provide at the application level, which is how Vue Router and the i18n libraries make themselves available everywhere without a wrapper component.
    • “When should you use Pinia instead?” - when it is state rather than a dependency. Provide/inject for a theme, an API client, a locale; Pinia for anything with actions, many subscribers, and a need for devtools history.