Skip to content

Dependency Injection in Vue 3 ​

In my years as an Angular Developer, I didn't fully appreciate the brilliance of Dependency Injection, and it had to do with not understanding how to use this design pattern effectively.

I didn't realize that it could help me decouple dependencies from my components. By injecting dependencies from the outside, the components no longer need to be aware of how their dependencies are created or managed, promoting a more modular and flexible architecture.

What is Dependency Injection? ​

Basically, it is a design pattern that has been around for many years. It has proven to be effective, and if your intention is to make sound architecture decisions for your web application, you should know it – well, not just that one, but all the other design patterns as well πŸ˜….

Component Composition ​

As you may know, we use Component Composition to build our applications, and Dependency Injection plays a fundamental role in this Component Composition.

Let's explore an example: ​

Imagine that we want to track user interactions in our application using some kind of Analytics service, such as Google Analytics, Pendo, or other similar services.

Since we don't know the future, we can anticipate that the Analytics service we use in our application might change in the future, and switching from one service to another should not have a significant impact on your application's code.

How can Component Composition help us with this?

Imagine you have two components <GoogleAnalytics /> and <Pendo />, and each of these components knows the service it will communicate with. These components can be replaced at any time without the need to modify any part of our code where tracking is done.

  • When my application uses Google Analytics:
vue
<template>
 <Layout>
  <GoogleAnalytics> 
    <Header>
     <!-- more child components here   -->
    </Header>
    <LeftSide>
      <!-- more child components here   -->
    </LeftSide>
  </GoogleAnalytics> 
 </Layout>
</template>
<template>
 <Layout>
  <GoogleAnalytics> 
    <Header>
     <!-- more child components here   -->
    </Header>
    <LeftSide>
      <!-- more child components here   -->
    </LeftSide>
  </GoogleAnalytics> 
 </Layout>
</template>
  • When my application uses Pendo:
vue
<template>
 <Layout>
  <Pendo> 
    <Header>
     <!-- more child components here   -->
    </Header>
    <LeftSide>
      <!-- more child components here   -->
    </LeftSide>
  </Pendo> 
 </Layout>
<template>
<template>
 <Layout>
  <Pendo> 
    <Header>
     <!-- more child components here   -->
    </Header>
    <LeftSide>
      <!-- more child components here   -->
    </LeftSide>
  </Pendo> 
 </Layout>
<template>

As you can see, by simply replacing one component with another, we can use one service or another. 😎

Ok, all of this is nice, but... How do we perform user action tracking if the components that know how to communicate with these services are far up in the component tree?

Provide / Inject ​

If you are not familiar with this feature of Vue.js, you should read that section first in the official documentation.

The Provide/Inject feature is what allows us to use Dependency Injection in Vue 3, although these are implementation details, and components should not be aware of them (in my opinion, of course).

But if the components are not aware of Provide/Inject, how do they know what they need to perform tracking?

The components of my application are aware of the Contract through which they will be doing tracking, and any services in my application must be able to comply with that Contract to enable tracking in the application.

The Contract ​

When my components need to report a user action, only know about the existence of a composable called useTracking.

vue
// inside UserSettings.vue

<script setup lang="ts">

 const track = useTracking();

 function addUser(user: User) {
   /* some logic here.....*/
   track('user_added', { name: user.name })
 }
</script>
// inside UserSettings.vue

<script setup lang="ts">

 const track = useTracking();

 function addUser(user: User) {
   /* some logic here.....*/
   track('user_added', { name: user.name })
 }
</script>

This Composable is the contract I established in my application for tracking purposes. The <GoogleAnalytics /> and <Pendo /> components must be able to provide whatever is needed inside that Composable:

ts
// inside useTracking.ts

import type { InjectionKey, Ref } from "vue";
import { inject } from "vue";

type TrackFn = (eventName: string, metadata: Record<string, unknown>) => void;

const defaultTrackingFn = (eventName, metadata) =>  console.log(`[${eventName}]`, JSON.stringify(metadata));

export const TrackingContext = Symbol("TrackingContext") as InjectionKey<TrackFn>;

function useTracking() { 
  const context = inject(TrackingContext, defaultTrackingFn);
  return context;
}

export default useTracking;
// inside useTracking.ts

import type { InjectionKey, Ref } from "vue";
import { inject } from "vue";

type TrackFn = (eventName: string, metadata: Record<string, unknown>) => void;

const defaultTrackingFn = (eventName, metadata) =>  console.log(`[${eventName}]`, JSON.stringify(metadata));

export const TrackingContext = Symbol("TrackingContext") as InjectionKey<TrackFn>;

function useTracking() { 
  const context = inject(TrackingContext, defaultTrackingFn);
  return context;
}

export default useTracking;

Knowing the contract that must be fulfilled, we just need to comply with it in the implementation the components:

<GoogleAnalytics /> :

vue
// inside GoogleAnalytics.vue

<script setup lang="ts">

 function track(eventName: string, metadata: Record<string, unknown>) {
    // Here I would communicate with the Google Analytics service
 }

 provide(TrackingContext, trackFn);

</script>
// inside GoogleAnalytics.vue

<script setup lang="ts">

 function track(eventName: string, metadata: Record<string, unknown>) {
    // Here I would communicate with the Google Analytics service
 }

 provide(TrackingContext, trackFn);

</script>

<Pendo /> :

vue
// inside Pendo.vue

<script setup lang="ts">

 function track(eventName: string, metadata: Record<string, unknown>) {
    // Here I would communicate with the Pendo service
 }

 provide(TrackingContext, trackFn);

</script>
// inside Pendo.vue

<script setup lang="ts">

 function track(eventName: string, metadata: Record<string, unknown>) {
    // Here I would communicate with the Pendo service
 }

 provide(TrackingContext, trackFn);

</script>

And that's it! 😎 You can play around by removing or adding components, and your application will switch services without any drama.

Mandatory Context ​

In some use cases I've worked on, it becomes mandatory to have a provided context.

Let's explore an example: ​

vue
<template>
  <Tabs>
    <TabList>
      <Tab>Milton H. Erickson</Tab>
      <Tab>UG. Krishnamurti</Tab>
    </TabList>
    <TabPanels>
      <TabPanel>
        Milton Erickson was an American psychiatrist
        and psychologist specializing in medical hypnosis.
      </TabPanel>
      <TabPanel>
        Uppaluri Gopala Krishnamurti was an intellectual
        who questioned the state of spiritual enlightenment.
      </TabPanel>
    </TabPanels>
  </Tabs>
</template>
<template>
  <Tabs>
    <TabList>
      <Tab>Milton H. Erickson</Tab>
      <Tab>UG. Krishnamurti</Tab>
    </TabList>
    <TabPanels>
      <TabPanel>
        Milton Erickson was an American psychiatrist
        and psychologist specializing in medical hypnosis.
      </TabPanel>
      <TabPanel>
        Uppaluri Gopala Krishnamurti was an intellectual
        who questioned the state of spiritual enlightenment.
      </TabPanel>
    </TabPanels>
  </Tabs>
</template>

This example is a component design pattern known popularly as: Compound Component


In the previous example, we see several components involved: <Tabs />, <TabList />, <Tab />, <TabPanels /> and <TabPanel /> , each component have specific responsibility. Behind the scenes, there is communication that developers do not see when using these components, for example:

The developer doesn't see where all the logic related to the selection of Tabs is, nor where clicking on a <Tab /> will hide one <TabPanel /> and show another; all of that happens behind the scenes, using Dependency Injection.

In this use case, some components are using the useTabs Composable to know which Tab is active and how to change the selection. Therefore, using the useTabs contract outside the context of a <Tabs /> component doesn't make sense.

ts
import type { InjectionKey, Ref } from "vue";
import { inject, provide } from "vue";

type State = {
  selectedIndex: Ref<number | null>;
  setSelectedIndex(index: number): void;
};

export const TabsContext = Symbol("TabsContext") as InjectionKey<State | null>;

function useTabs() { 
  const context = inject(TabsContext, null);

  if (context === null) {
    throw new Error(`Missing a parent <Tabs /> component.`);
  }

  return context;
}
import type { InjectionKey, Ref } from "vue";
import { inject, provide } from "vue";

type State = {
  selectedIndex: Ref<number | null>;
  setSelectedIndex(index: number): void;
};

export const TabsContext = Symbol("TabsContext") as InjectionKey<State | null>;

function useTabs() { 
  const context = inject(TabsContext, null);

  if (context === null) {
    throw new Error(`Missing a parent <Tabs /> component.`);
  }

  return context;
}

HTML fieldset tag ​

The <fieldset> tag is used to group related elements in a form:

html
<form>
 <fieldset>
  <label for="fname">First name:</label>
  <input type="text" id="fname" name="fname"><br><br>
  <label for="lname">Last name:</label>
  <input type="text" id="lname" name="lname"><br><br>
  <input type="submit" value="Submit">
 </fieldset>
</form>
<form>
 <fieldset>
  <label for="fname">First name:</label>
  <input type="text" id="fname" name="fname"><br><br>
  <label for="lname">Last name:</label>
  <input type="text" id="lname" name="lname"><br><br>
  <input type="submit" value="Submit">
 </fieldset>
</form>

One of the features of this tag is its ability to disable all child input tags 😱:

html

 <fieldset disabled>
     <!-- a lot inputs tags here   -->
 </fieldset>

 <fieldset disabled>
     <!-- a lot inputs tags here   -->
 </fieldset>

This combination, along with Dependency Injection, has inspired some great patterns, such as the Skeleton pattern and the Core components of an application.

Let's explore an example: ​

Imagine that you have to display some Skeleton components in your application when there is a pending HTTP request:

vue

<script setup>
 const { isLoading, data, error } = useFetch('/user/d7120dfeeb5915b8');
</script>

<template>
  <EditUser :user="data">
    <Button @click="addNewUser" v-if="isLoading">Add</Button>
    <ButtonSkeleton v-else />
    <Button @click="removeUser" v-if="isLoading">Remove</Button>
    <ButtonSkeleton v-else />
    <Description v-if="isLoading" />
    <DescriptionSkeleton v-else />
  </EditUser>
</template>

<script setup>
 const { isLoading, data, error } = useFetch('/user/d7120dfeeb5915b8');
</script>

<template>
  <EditUser :user="data">
    <Button @click="addNewUser" v-if="isLoading">Add</Button>
    <ButtonSkeleton v-else />
    <Button @click="removeUser" v-if="isLoading">Remove</Button>
    <ButtonSkeleton v-else />
    <Description v-if="isLoading" />
    <DescriptionSkeleton v-else />
  </EditUser>
</template>

How would it look if we used Dependency Injection and the approach of our beloved <fieldset> :

vue

<script setup>
 const { isLoading, data, error } = useFetch('/user/d7120dfeeb5915b8/');
</script>

<template>
  <Skeleton :isLoading="isLoading">
    <EditUser :user="data">
      <Button @click="addNewUser">Add</Button>
      <Button @click="removeUser">Remove</Button>
      <Description />
    </EditUser>
  </Skeleton>
</template>

<script setup>
 const { isLoading, data, error } = useFetch('/user/d7120dfeeb5915b8/');
</script>

<template>
  <Skeleton :isLoading="isLoading">
    <EditUser :user="data">
      <Button @click="addNewUser">Add</Button>
      <Button @click="removeUser">Remove</Button>
      <Description />
    </EditUser>
  </Skeleton>
</template>

With this approach, each component knows when to display itself or when to show its Skeleton version.

Each of them, in their implementation, uses the contract: useSkeleton. If there is a parent component called <Skeleton />, they will obtain from this context the necessary data to know when to display themselves:

vue

// inside Button.vue

<script setup>
 const isPending = useSkeleton();
</script>

 <template>
   <ButtonSkeleton v-if="isPending" />
   <button v-else class="button"><slot /></button>
 </template>

// inside Button.vue

<script setup>
 const isPending = useSkeleton();
</script>

 <template>
   <ButtonSkeleton v-if="isPending" />
   <button v-else class="button"><slot /></button>
 </template>

That's all! πŸ€ͺ

Personal blog by Angel NΓΊnez