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:
- 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.
- 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:
defaultValue
: This is the value that should be returned in the event a provided value is not found with that key.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 tellsinject
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!