Vue.js Provide/Inject has been around for years and for the most part it has been used by library authors to pass things around, especially with higher-order components and provider component patterns. In this article, I will show you what I learned to make the most out of this API in production apps.

Primer

If you are unfamiliar with the provide/inject API, here is a quick rundown.

Some components can declare “dependencies” to be available to all child components in the hierarchy. In other words, it “provides” some dependency to child components.

jsimport { provide, reactive } from 'vue';

const user = reactive({
  id: 1,
  name: 'John Doe',
  email: 'johndoe@example.com',
});

provide('AUTH_USER', user);

The children of that component can then request that this dependency be “injected” into them.

jsimport { inject } from 'vue';

const user = inject('AUTH_USER');

So when should you use them?

Library authors use them all the time to pass complex contextual information around their components and composition functions. But in contrast to props, they can pass those dependencies without you “the consumer” noticing. Meaning they are invisible to you and it abstracts away the pain of forcing you “the consumer” to pass these dependencies around.

For example, vee-validate passes something called FormContext to all of its fields. This allows all input fields to be associated automatically with the form without you having to pass it around every single one.

In a way it is used to simplify the usage of complicated components. But for you in a production app, their usage can be very flexible.

Let me give you an IRL example here. Let’s say you have a logged-in user, a lot of components would be interested in getting that info and may need to perform actions on it. This sounds very similar to state stores, and actually, a lot of state store implementations make use of provide/inject in that same way but hide it behind prettier well-named functions.

So to organize access to logged-in user data, you can create a “data context”. Here is a rough example:

jsimport { provide, ref } from 'vue';

export function useAppUserContext() {
  const user = ref(null);
  onMounted(() => {
    // Fetch the logged in user
    user.value = await fetch('https://tes.t/me').json();
  });

  function logout() {
    user.value = null;
  }

  function update(value) {
    user.value = { ...user.value, ...value };
  }

  const ctx = {
    user,
    update,
    logout
  };

  provide('AUTH_USER', ctx);

  return ctx;
}

This isolated function probably will be called in your root app component’s setup function. And that makes all child components able to inject the user context and use it whenever they need. So it is very similar to stores but and is also the mechanism that allows you to create your own powerful versions of them.

The idea of having “contexts” that other components can access or change is not new. In the React world, “contexts” are more exposed and used frequently than I would say for the Vue world and usually, it is hidden away by lib authors to manage their complexities and caveats. In the following sections, I will share some tips to address most of their caveats.

Use the composition API

While the provide/inject API does have an options API variant, I prefer to use it strictly with the composition API because it allows you to pass complex reactive objects along with functions and other stuff. The official example even uses a hint of composition functions mixed with the options API.

So, use the composition API. And the chances are, if you are creating your own data contexts, you are probably using the composition API already.

Use Injection Keys

In the first example, you’ve seen using a string key is a simple way to declare the injection keys for a dependency. However, there are a few things you should adhere to here.

Using inline strings will not scale well, as a single typo will either cause the app to run incorrectly or simply crash it.

js// ❌ OOps!
provide('AUTH_USR', ctx);

So do yourself a favor and declare a root injectionKeys.js file that contains all the injection keys you use in your app and prevent the usage of inline key values.

jsexport const AUTH_USER_KEY = 'Authenticated User';

export const CART_KEY = 'User Cart';

Then whenever you need them you will have to import them, which ensures that all the developers in your team don’t get a key name wrong and thus will ensure everything is injected/provided correctly.

Here is an example usage:

In the provider function/component:

jsimport { provide } from 'vue';
import { AUTH_USER_KEY } from '@/injectionKeys';

const user = reactive({ id: 1, name: 'John Doe' });
provide(AUTH_USER_KEY, user);
jsimport { inject } from 'vue';
import { AUTH_USER_KEY } from '@/injectionKeys';

const user = inject(AUTH_USER_KEY);

Another problem is when you use more injections, you will have to create more keys and you will need to make sure each has a truly unique value. This can be a non-issue for you but if you have around 100 injections in your app you can fall into that problem easily.

jsexport const AUTH_USER_KEY = 'USER';
// some injections later...
export const CURRENT_USER_KEY = 'USER';

Granted, this happened because I used horrible values for the strings. You probably will never do that but what if I have told you there is a way to avoid the issue even if you give the same string description to all of your keys.

ES6 has introduced an interesting type that is not used very often. I’m talking about Symbols, symbols are unique values that can be given a description and are always unique once created, they can be also used as object keys.

This means we can use them for the injections keys here:

jsexport const AUTH_USER_KEY = Symbol('USER');
// some injections later...
export const CURRENT_USER_KEY = Symbol('USER');

Now even though both symbols have the same description, they are not equal and thus can be used just fine without any kind of conflict. So always use symbols with injection keys, they will save you a few hours of debugging down the road when things go wrong.

Use TypeScript

I think using provide/inject in your production app automatically forces you to use TypeScript. You and your teammates won’t memorize what each injection will give you.

Allow me to give you a glimpse of the potential hell you will face here.

So let us say your teammates worked on the user cart feature, and just added it to the project. You are using JavaScript in that project. You want to use that injection to display the product count. For example:

vue<template>
  <p>You have {{ cart.items.length }} in your cart</p>
</template>

<script setup>
import { inject } from 'vue';
import { USER_CART_KEY } from '@/injectionKeys';

const cart = inject(USER_CART_KEY);
// does `cart.items` actually exist?
// what if the injected user cart is the array itself?
</script>

Even though you are using constant keys to refer to your injection, it has 0 information about the injection value itself. It can be anything.

So you will be forced to hunt down the provide(USER_CART, ...) line in your codebase to figure out what is being injected. From a productivity concern, this is a nightmare and If you plan to use them a lot in your project it will backfire and waste a lot of your time figuring stuff out. Honestly, if you want to use injections in your project use TypeScript or don’t use them.

So how to use TypeScript with injections?

Vue exposes a type called InjectionKey<TValue> which magically lets provide/inject infer and check the type of the values passed around. Here is a simple example:

ts// types.ts
interface UserCartContext {
  items: CartItem[];
  total: number;
}

// injectionKeys.ts
import { InjectionKey } from 'vue';
import { UserCartContext } from '@/types';

const CART_KEY: InjectionKey<UserCartContext> = Symbol(
  'User Cart Context',
);

This will give you type checks at both the provide and inject levels, which is not something you should give up.

tsimport { provide, inject, reactive } from 'vue';
import { CART_KEY } from '@/injectionKeys';

// ❌ Type Error
provide(CART_KEY, {});

const cart = reactive({ items: [], total: 0 });
// ✅
provide(CART_KEY, cart);


const cart = inject(CART_KEY);
// ❌ Type Error
cart?.map(...);
// ✅
cart?.items.map(...)

I have covered this very topic intensively here with some bonus tips, so check Type-safe Vue.js Injections.

Props vs Injections

I wanted to slow down here and discuss when to use props and when to use injections. Because there isn’t a clear limitation on how to use injections, you can abuse them and completely ignore props. But I would like to make it clear for me when to reach for either.

To me props are direct simple dependencies you need to pass to components, lets go back to the very first logged-in user example.

If you are creating a <UserAvatar /> component, you should use props here because you want the avatar component to be flexible. So you can display the avatar for whatever kind of user in your app, limiting it to the currently logged-in one doesn’t make a lot of sense.

But if you are creating a <LogoutBtn /> button component, then it is very much concerned with that user context. It is “focused” towards it. Then using the user context injection here makes more sense than passing the logout function as a prop or as a handler to a click event.

Another good example is the user cart, you don’t want to pass it around the entire app. So instead you will provide it at a global level then inject it wherever necessary. The decision of “when to inject” is very similar to “when to use a data store”. If you are using Pinia or Vuex, the decision is very similar to them because they also provide these data contexts, so the same rules apply. Except yours are manually crafted so they can be much more flexible.

Also, it avoids the issue of prop drilling, which is passing props far down the component tree.

This illustration from the Vue docs explains it:

Attaching an administrator policy for our new user

To summarize, if you find yourself doing any of the following:

  • Pass a piece of object data to a lot of deeply nested child components
  • Needing global level read/write access to a piece of data

Then you probably should use injections here.

If you nail down these tips so far, then you are ready to abuse the inject API.

Requiring Injections

Now that we’ve established when you should use injections instead of props, you probably need a clear mechanism to “require” injecting them.

Let’s go back to the cart example:

tsimport { inject } from 'vue';
import { CART_KEY } from '@/injectionKeys';

const cart = inject(CART_KEY);
// typeof cart is `UserCartContext | undefined`
cart?.items.map(...);

Did you notice the usage of the optional chaining ?. operator here? this is because inject by default doesn’t assume it can always resolve the injection, what if you forgot to provide the injection value?

This is a very important design decision, the return type of inject will always be nullable because there is always a possibility that provide was not called for that injection up the parent tree.

To get around this, you can provide a default value just like props:

tsimport { inject } from 'vue';
import { USER_CART_KEY } from '@/injectionKeys';

const cart = inject(USER_CART_KEY, { items: [], total: });
// Now safe to access `cart.items.map`
cart.items.map(...);

But that’s not good enough, just like props this only works when the value isn’t needed and you can make do with the default value. But in the case of injecting complex context objects that won’t work. You need a way to require the provide call before anyone attempts to use this component.

You can do something like this:

tsimport { inject } from 'vue';
import { USER_CART_KEY } from '@/injectionKeys';

const cart = inject(USER_CART_KEY);
if (!cart) {
  throw new Error('WHERE IS THE CART INJECTION???');
}

// Here is safe to access `cart.items.map`
cart.items.map(...);

Throwing exceptions is fine here. Because this injection is required, we want to say there is no way this component works without that injection. This is a very effective way to declare that injection intent and makes it easier for your fellow developers to debug missing injections.

But doing this for almost every single injection isn’t convenient. Let’s clean this up in a new utility function:

ts// other possible name was: `yoloInject`.
function injectStrict<T>(key: InjectionKey<T>, fallback?: T) {
  const resolved = inject(key, fallback);
  if (!resolved) {
    throw new Error(`Could not resolve ${key.description}`);
  }

  return resolved;
}

This is one of the most useful functions I have been using in production apps. It uses the same inject behavior but with a twist. If it fails to resolve an injection it will throw an exception, and this makes it much cleaner to work with.

Let’s re-write the example from before:

tsimport { injectStrict } from '@/utils';
import { USER_CART_KEY } from '@/injectionKeys';

const cart = injectStrict(USER_CART_KEY);

// Here is safe to access `cart.items.map`
cart.items.map(...);

Injection Scopes

One of the things you need to understand about injections is that they are always provided for child components scope. Meaning you can’t use them in the same component providing the injection. This may look straightforward and may not be even needed but let me show an example where I did need access to the injection on the same component providing it.

In my work at Octopods, we have a bunch of initial data that we are loading for the app, which includes the logged-in user data, their subscription details, and their settings. And to keep things scalable we have each of these pieces of logic organized as data contexts functions: useAppUserContext, useSubscriptionContext, and useUserSettingsContext.

Then to load them all at the same time we have another function that calls all 3 of them. An initializer of sorts.

This useInitApp function manages the initial data is responsible for invaliding the cache and tracking the progress of how these data are fetched. To keep it simple, let’s just settle for calling all 3 of them.

tsexport function useInitApp() {
  useAppUserContext();
  useSubscriptionContext();
  useUserSettingsContext();
  // other logic
}

This useInitApp function is then called in the root App.vue, but the problem is we need access to AppUserContext to display some stuff:

vue<template>
  <p>User name is {{ user.name }}</p>
</template>

<script setup>
import { inject } from 'vue';
import { AUTH_USER_KEY } from '@/injectionKeys';
import { useInitApp } from '@/features/init';

useInitApp();

// won't work!
const user = inject(AUTH_USER_KEY);
</script>

How do we get access to the user.name without calling useAppUserContext again? we don’t want to call it twice, and we don’t want to remove it from the useInitApp function because we might lose other app cycle management logic.

A simple solution could be to let useInitApp return all the information from these contexts:

tsexport function useInitApp() {
  const userCtx = useAppUserContext();
  const subCtx = useSubscriptionContext();
  const settingsCtx = useUserSettingsContext();
  // other logic

  return {
    ...userCtx,
    ...subCtx,
    ...settingsCtx,
  };
}

// then in App.vue setup
const { user } = useInitApp();

This could work, but for me useInitApp shouldn’t do that because it is responsible for fetching these data and managing their lifetime. So the problem here is that we have some injections hiding in a couple of layers deep in function calls. Exposing them via returns is the reverse of prop drilling, you are drilling these data outwards till you can consume them. It is kind of ironic that prop drilling is solved using injections, and yet you may find yourself doing the opposite if you use them too much.

As a library author, I needed the scope of injections to include the same providing component, I will provide (pun intended) a couple of examples.

In vee-validate I wanted users to be able to call useForm and useField in the same component:

jsimport { useField, useForm } from 'vee-validate';

const form = useForm(...);
// same level injection, won't work!
const field = useField(...);

In villus I wanted users to be able to configure their GraphQL client with useClient and then be able to query it immediately with useQuery in the same component:

jsimport {useQuery, useClient} from 'villus';

useClient(...);
// won't work because it is called in the same component level
const { data } = useQuery(...);

So I needed a solution for this, and actually, it used to work in the early releases of Vue 3 but it was reverted because it wasn’t consistent with the options API behavior.

This brings us to another custom utility function I use:

tsimport { getCurrentInstance, inject, InjectionKey } from 'vue';

export function injectWithSelf<T>(
  key: InjectionKey<T>,
): T | undefined {
  const vm = getCurrentInstance() as any;

  return vm?.provides[key as any] || inject(key);
}

What this function does is, before attempting to resolve the provided dependency with inject. It attempts to locate it within the same component provided dependencies. Otherwise, it will follow the same behavior as inject. You can swap inject with injectStrict here and get a stricter variant of this new function:

tsimport { getCurrentInstance, inject, InjectionKey } from 'vue';

export function injectStrictWithSelf<T>(
  key: InjectionKey<T>,
): T | undefined {
  const vm = getCurrentInstance() as any;

  return vm?.provides[key as any] || injectStrict(key);
}

So now the original example will work fine:

vue<template>
  <p>User name is {{ user.name }}</p>
</template>

<script setup>
import { AUTH_USER_KEY } from '@/injectionKeys';
import { injectStrictWithSelf } from '@/utils';
import { useInitApp } from '@/features/init';

useInitApp();

// Now works!
const user = injectStrictWithSelf(AUTH_USER_KEY);
</script>

You will need to be careful using this function because the call order of your injection will matter here. Here is what will happen if you do your calls in the wrong order:

js// ❌ Throws an exception because the `provide` function wasn't called yet
const user = injectStrictWithSelf(AUTH_USER_KEY);

useInitApp();

You probably won’t need it a lot, but it is another useful tool in your injections belt.

Singleton Injections

The last tip is an experimental pattern I’m still trying out but so far it is working fine. And it does solve the last call order issue I pointed out. Let’s dive in.

The only gripe I have left with injections after incorporating all of these techniques I mentioned, is they are still hard to use. If a teammate needs to inject some value into their component, they must know the injection key corresponding to the value they need.

For example, for them to get access to AppUserContext object, they need to know that USER_USER_KEY is the key that they should use to inject it. So without thinking about this you will end up having a lot of inject calls all over your app. The previous tips made it very safe to use them, we want to make it easier.

I would argue your teammates may be more inclined to just call useAppUserCtx and get the user data, but as things currently stand, that’s bad because it was already called at the root app level. But they are not wrong, it is more intuitive to call the function than figure out the injection key. And I noticed this applies more to injections that “must be called once” which is usually complex global data contexts.

So our task now is to make calling useAppUserContext anywhere in the app safe without worrying about duplicating the work done in the initial call.

What we need to do, is check if the AppUserContext was created before and if it did we will just return it. Otherwise, we will create it from scratch.

jsimport { provide } from 'vue';
import { CURRENT_USER_CTX } from '@/injectionKeys';
import { injectWithSelf } from '@/utils';

export function useAppUserContext() {
  const existingContext = injectWithSelf(CURRENT_USER_CTX, null);
  if (existingContext) {
    return existingContext;
  }

  const user = ref(null);
  onMounted(() => {
    // Fetch the logged in user
    user.value = await fetch('https://tes.t/me').json();
  });

  function logout() {
    user.value = null;
  }

  function update(value) {
    user.value = { ...user.value, ...value };
  }

  const ctx = {
    user,
    update,
    logout
  };

  provide(CURRENT_USER_CTX, ctx);

  return ctx;
}

With this, no matter how many times and wherever you call useAppUserContext. The code will be called once, rest of the calls will use the already injected instance. You also won’t have to call inject anymore in your components. If you use this pattern then you will use the context function to inject itself which is much nicer and more intuitive than using inject and our custom variants directly.

Note that this approach ensures that an injection is only created once in a single component tree.

Conclusion

I shared with you all the lessons I learned while using provide/inject extensively in my daily work. The goals we’ve set out to achieve are all about making them easier and safer to use using a variety of techniques that involved Type checking and throwing exceptions.

We also built variants of the original inject function that allowed us to tweak the original behavior to fit certain needs.

With all of these tools readily available in your tool belt, you can use injections more boldly than before.

Join The Newsletter

Subscribe to get notified of my latest content