Provide/Inject API With Vue 3

Introduction

One of the most difficult problems to solve when building single-page applications is state management. With component-based frameworks like Vue, this is typically solved in one of two ways:

  1. State is managed by components, with data being passed to child components as props. The parent component then listens for events and performs actions on the state accordingly.
  2. State is managed with a global state management library (Vuex, Redux, etc). Global state is then injected into the desired components, and those components triggers actions in the state management library (such as API requests or data updates). This can provide a layer of separation between logic and templating which is useful, and helps with passing data between parts of the application.

Vue offers an interesting middleground between these two approaches with the Provide/Inject API. This API is similar to React's Context API, in that it allows a component to provide some sort of data to any component beneath it in the component tree. For example, a parent component could provide a piece of data (say, the user's username), and then a grandchild component could inject that value into itself.

Provide/Inject gives developers a way to share data between parent and child components while avoiding prop drilling (passing a prop from one component to the next in a chain). This can make your code more readable, and reduce the complexity of your props for any components that don't rely on this data.

In this article, we will explore a basic example using the Provide/Inject API, building up a new application with a user dashboard to update their name and email.

Getting Started - Using Props

We'll first set up our example application using the standard approach to passing data between components - props. Below is our homepage and a navigation page.

<!-- App.vue -->
<template>
  <div class="w-2/3 pt-6 m-auto">
    <Nav :name="state.name" />
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive } from "vue";
import Nav from "./components/Nav.vue";

export default defineComponent({
  setup() {
    const state = reactive({
      name: "Bob Day",
      email: "bob@martianmovers.com",
    });

    return { state };
  },
  components: {
    Nav,
  },
});
</script>
<!-- Nav.vue -->
<template>
  <nav class="flex">
    <div class="flex-grow">
      <a class="px-4 hover:underline" href="#">Home</a>
      <a class="px-4 hover:underline" href="#">About</a>
      <a class="px-4 hover:underline" href="#">My Account</a>
    </div>
    <div class="flex-shrink">
      Hello, {{ name }}!
    </div>
  </nav>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  props: {
    name: {
      type: String,
      default: "User"
    }
  },
  setup(props) {
    return {
      ...props
    }
  },
})
</script>

This is a very straightforward parent/child structure. Our parent component has a reactive object (state) with a user's name and email. The username is passed into the Nav component as a prop, and then displayed. This works well because we only have two components, but what if there were other layout components between the root and the navigation component?

We can use Provide/Inject to send this data from the parent to the navigation component. In our parent component, we will provide the data we want available (the username), and then inject that data into the Nav component.

Provide/Inject with Composition API

Let's start with App.vue, and explore how to use provide. Below is our rewritten root component:

<!-- App.vue -->
<template>
  <div class="w-2/3 pt-6 m-auto">
    <Nav />
  </div>
</template>

<script lang="ts">
import { computed, defineComponent, provide, reactive } from "vue";
import Nav from "./components/Nav.vue";

export default defineComponent({
  setup() {
    const state = reactive({
      name: "Bob Day",
      email: "bob@martianmovers.com",
    });

    provide('username', computed(() => state.name));

    return { state };
  },
  components: {
    Nav,
  },
});
</script>

With Vue 3, we have access to a Composition API method provide. This function takes a key and a value. The key is how the provided value will be accessed in other components. In this example. we are passing a computed property with the user's username as the value.

Why are we passing a computed property? By default, the provided value is not reactive. If we just wrote provide('username', state.name), any component that injected this value would only have the initial state of the username. If it were to change in the future, the name would be out of sync with the root component. If we wanted to provide the entire state, we could write this instead:

provide('state', state);

That's because we're using a reactive object for our state. Alternatively, if the username was a ref, we could also use that in the same way.

Keep in mind that any value could be passed as an argument to provide, including functions. This will come up later in our example, but it's important to think about when you're wanting to use this API.

Let's look our our navigation component now, using inject to get the value of state.name.

<!-- Nav.vue -->
<template>
  <nav class="flex">
    <div class="flex-grow">
      <a class="px-4 hover:underline" href="#">Home</a>
      <a class="px-4 hover:underline" href="#">About</a>
      <a class="px-4 hover:underline" href="#">My Account</a>
    </div>
    <div class="flex-shrink">
      Hello, {{ name }}!
    </div>
  </nav>
</template>

<script lang="ts">
import { defineComponent, inject } from 'vue'
export default defineComponent({
  setup() {
    const name = inject('username');

    return {
      name
    }
  },
})
</script>

Similar to what we did in App.vue, we used a Composition API method called inject to get the value. inject takes the key we used when providing the data, and then returns the value as a variable. Since we provided a computed property, inject returns a computed property as well.

inject has two other arguments as well:

  1. defaultValue: This is the value that should be returned in the event a provided value is not found with that key.
  2. treatDefaultAsFactory: As I noted above, any value (including functions) can be provided as a value to inject. In the event that a function is what you are providing, you don't want it to be invoked by mistake. But what if you're providing an object? Unless it is returned from a function, you could end up with duplicate objects that have the same reference (this is also why data and props recommend returning objects from a function rather than setting them directly). When using a function as the default value, this argument tells inject whether the default is the function itself, or the value returned by the function.

The two below examples return the same default, a string:

// With a default value
const name = inject('usernafme', 'Bob Day');

// With a default factory
const name = inject('usernafme', () => 'Bob Day', true);

In our component example, the username is being injected and returned from setup, making it available in the template. We can then use that variable as we normally would, but without having to worry about props. Nice! This could save us a lot of time and effort with prop drilling.

Providing Reactivity

In the last section, we discussed reactivity and how the argument in provide needs to be a reactive object if we want data to stay in sync. Let's build out our user dashboard so they can update their name and email. In our App.vue, we're going to add a single line to our setup method:

provide('userDetails', state);

This will provide the entire state object (a reactive object) to whatever component wants to inject it. Now, let's build out a dashboard to work with that data:

<!-- MyProfile.vue -->
<template>
  <div class="flex flex-col">
    <h2 class="block m-auto text-2xl">My Profile</h2>
    <hr />
    <label class="py-1 flex">
      <span class="w-24">Username: </span>
      <input class="shadow p-1 bg-gray-100 w-64" v-model="userDetails.name" />
    </label>
    <label class="py-1 flex">
      <span class="w-24">Email:</span>
      <input
        class="shadow p-1 bg-gray-100 w-64"
        type="email"
        v-model="userDetails.email"
      />
    </label>
  </div>
</template>

<script lang="ts">
import { defineComponent, inject } from "vue";

export default defineComponent({
  setup() {
    const userDetails = inject("userDetails");

    return {
      userDetails,
    };
  },
});
</script>

In this component, we inject the entire userDetails provided value, and return it to the template. We can then use v-model to bind directly to the injected values. With this in place, it all works as expected! Any changes made to the username field would properly update in the navigation as well.

However, there's a small catch to doing things this way. Per the Vue 3 documentation, "When using reactive provide / inject values, it is recommended to keep any mutations to reactive properties inside of the provider whenever possible." The reason for this is that allowing any child component to mutate a value could lead to confusion about where a particular mutation is happening. The more disciplined our codebase is about mutating state, the more stable and predictable it will be.

Rather than directly using v-model on our reactive state, let's provide a couple functions that will do the updating for us. First, we'll update App.vue to handle the new providers:

<!-- App.vue -->
<template>
  <div class="w-2/3 pt-6 m-auto">
    <Nav />
    <main class="py-6">
      <MyProfile />
    </main>
  </div>
</template>

<script lang="ts">
import { computed, defineComponent, provide, reactive } from "vue";
import Nav from "./components/Nav.vue";
import MyProfile from "./components/MyProfile.vue";

export default defineComponent({
  setup() {
    const state = reactive({
      name: "Bob Day",
      email: "bob@martianmovers.com",
    });

    const updateUsername = (name) => {
      state.name = name;
    };

    const updateEmail = (email) => {
      state.email = email;
    };

    provide(
      "username",
      computed(() => state.name)
    );
    provide("userDetails", state);
    provide("updateUsername", updateUsername);
    provide("updateEmail", updateEmail);

    return { state };
  },
  components: {
    Nav,
    MyProfile,
  },
});
</script>

We have added two functions - updateUsername and updateEmail. These functions are nearly identical, just updating the value on our state object that they are associated to. We then provide these two functions using the provide method, so that they are available to children components.

Remember above when we discussed that any value could be provided? This is why treatDefaultAsFactory is important. Here, we are providing two functions that don't return anything. If inject by default invoked the function and returns its value, we would get undefined is not a function errors in our child component. In this case, we're really wanting a function to be injected into our component, so defaulting treatDefaultAsFactory to false is excellent.

Here's the updated code for MyProfile.vue:

<!-- MyProfile.vue -->
<template>
  <div class="flex flex-col">
    <h2 class="block m-auto text-2xl">My Profile</h2>
    <hr />
    <label class="py-1 flex">
      <span class="w-24">Username: </span>
      <input class="shadow p-1 bg-gray-100 w-64" v-model="username" />
    </label>
    <label class="py-1 flex">
      <span class="w-24">Email:</span>
      <input class="shadow p-1 bg-gray-100 w-64" type="email" v-model="email" />
    </label>
  </div>
</template>

<script>
import { defineComponent, inject, computed } from "vue";

export default defineComponent({
  setup() {
    const userDetails = inject("userDetails");
    const updateUsername = inject("updateUsername");
    const updateEmail = inject("updateEmail");

    const username = computed({
      get: () => userDetails.name,
      set: updateUsername,
    });

    const email = computed({
      get: () => userDetails.email,
      set: updateEmail,
    });

    return {
      username,
      email,
    };
  },
});
</script>

Rather than binding directly on userDetails, we created two computed properties, each with a getter and setter. We can then bind to the computed properties, since they will return the desired value (username or email) and trigger the update methods we injected. Now our reactivity is fully controlled by the root component, App.vue, rather than the child.

Global State Management

I mentioned above that Provide/Inject gives us a middle ground between global state and component state. With the introduction of the Composition API, however, there's no reason we can't use Provide/Inject as our global management. Let's take everything we've written so far and extract it to a separate file:

import { computed, inject, provide, reactive } from "vue";

export const initStore = () => {
  // State
  const state = reactive({
    name: "Bob Day",
    email: "bob@martianmovers.com",
  });

  // Getters
  const getUsername = computed(() => state.name);
  const getEmail = computed(() => state.email);

  // Actions
  const updateUsername = (name) => {
    state.name = name;
  };
  const updateEmail = (email) => {
    state.email = email;
  };

  provide("getUsername", getUsername);
  provide("getEmail", getEmail);
  provide("updateUsername", updateUsername);
  provide("updateEmail", updateEmail);
};

export const useStore = () => ({
  getUsername: inject("getUsername"),
  getEmail: inject("getEmail"),
  updateUsername: inject("updateUsername"),
  updateEmail: inject("updateEmail"),
});

In this file, we have two functions - initStore and useStore. initStore creates our reactive object, getters for both the username and email, and methods to perform updates, then provides each of those values. These three groups (state, computed, and methods) maps very nicely to how Vuex works (state, getters, and actions).

The second method, useStore, simply returns an object with the injected values. This lets us use the store we've created from a single location, so if we change the key used in provide, we can also update it in the inject. This ensures we aren't duplicating our inject calls, and we only have one file to check if something goes wrong.

Our App.vue file is now a lot simpler:

import { defineComponent } from "vue";
import { initStore } from "./store/store";
import Nav from "./components/Nav.vue";
import MyProfile from "./components/MyProfile.vue";

export default defineComponent({
  setup() {
    initStore();
  },
  components: {
    Nav,
    MyProfile,
  },
});

Since we don't need the store values in our root component, we can safely call initStore to generate the store, and provide its values to our child components. Then, in MyProfile.vue, we can do the following:

import { defineComponent, computed } from "vue";
import { useStore } from "../store/store";

export default defineComponent({
  setup() {
    const store = useStore();

    const username = computed({
      get: () => store.getUsername.value,
      set: store.updateUsername,
    });

    const email = computed({
      get: () => store.getEmail.value,
      set: store.updateEmail,
    });

    return {
      username,
      email,
    };
  },
});

Because useStore injects the values for us, we have access to the username, password, and their update methods. This is one of the ways that the Composition API can help keep our code clean, and our logic bundled by feature rather than functionality.

If this concept interests you, there's a library for Vue 2 and 3 called Pinia that takes this approach to the next level. Pinia provides you a typesafe, easy to maintain global store. Check it out!

Conclusion

Using Provide/Inject can help remove some of the complexity of passing data between parent and child components. Keep in mind that provided values do not have the same checks as props, such as required or type, so they are inherently less safe to use. There is no guarantee that the value you want to inject is present in the component tree, nor do you know for certain what shape that value is in.

Also, up until recently, the Vue documentation included, "provide and inject are primarily provided for advanced plugin / component library use cases. It is NOT recommended to use them in generic application code." This has since been removed, and libraries like Pinia show the power of using this API in application code. I would still recommend being careful when choosing to implement a feature using Provide and Inject. That said, have fun and try it out!

Here's a link to a Stackblitz example of the final form of the appliation we worked through above.

Until next time!

This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.

This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.

You might also like