Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

Shallow Dive into Injections in Vue

Refresh#

Library authors frequently use provide/inject API to transmit complex contextual information to their components and composition functions. Unlike props, which require explicit passing of dependencies by the consumer, dependency injection can seamlessly pass those dependencies without consumers even realizing it. This means that dependencies become invisible to consumers, effectively abstracting away unnecessary complexity.

import { 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 will likely be called within the setup function of your root app component. This enables all child components to effortlessly inject the user context and utilize it as needed. It's similar to stores in many ways, but also functions as a mechanism to empower users to create their own powerful iterations of them.

Use Injection Keys#

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.

export const AUTH_USER_KEY = 'Authenticated User';
export const CART_KEY = 'User Cart';

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.

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

Use TypeScript#

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:

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

import { 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(...)

It is worth noting that the inject function produces a resolved type in union with undefined. This is because there is a possibility that the injection is not resolved. It is up to the user on how to handle this situation. To eliminate the undefined, a fallback value has to be passed to the inject function. The interesting thing here is that the fallback value also needs to pass the type checking.

import { inject } from 'vue';
import { ProductKey } from '@/symbols';
// ⛔️ Argument of type 'string' is not assignable to ...
const product = inject(ProductKey, 'nope');
// ✅ Type checks out
const product = inject(ProductKey, { name: '', price: 0 });

While you can provide plain value types, they are not that useful as usually you need to react to these values changing. You can create reactive injections as well with generic types.
For reactive refs created with ref you can use the generic Ref type to type your InjectionKey, so it’s a nested generic type:

// types.ts
interface Product {
  name: string;
  price: number;
}
// symbols.ts
import { InjectionKey, Ref } from 'vue';
import { Product } from '@/types';
const ProductKey: InjectionKey<Ref<Product>> = Symbol('Product');
import { inject } from 'vue';
import { ProductKey } from '@/symbols';
const product = inject(ProductKey); // typed as Ref<Product> | undefined
product?.value; // typed as Product

Requiring Injections#

By default Vue displays a warning if it did not resolve an injection, Vue could’ve chosen to throw errors if an injection is not found but Vue can’t really make the assumption about whether the injection is required or not, so it’s up to you to make sense of unresolved injection and the undefined value.

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;
}
import { 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#

import { 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.

Singleton Injections#

import { 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;
}

By implementing this approach, irrespective of how many times and wherever you call useAppUserContext, the code will only run once. Subsequently, all other calls will use the already injected instance. As a result, there is no need to use inject in your components any longer. When you follow this pattern, you can use the context function to inject itself, which is much more intuitive and user-friendly than utilizing inject and our customized versions directly. It should be noted that this procedure guarantees that an injection is created only once in a single component tree.

References:
https://logaretm.com/blog/making-the-most-out-of-vuejs-injections/
https://logaretm.com/blog/type-safe-provide-inject/

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.