In Vue.js, tightly coupled components often share state or behavior that make them dependent on each other.
Examples of this include Avatar
and AvatarGroup
, Tabs
and TabPanel
, Accordion
and AccordionPanel
. You can see the Accordion example first hand in this video lesson from our "Crafting a Custom Component Library with Vue and Daisy UI” course.
This can be beneficial for certain patterns and use cases where components are inherently connected. One such pattern is using provide
and inject
to create a communication bridge between parent and child components. In this blog post, we'll explore how to use provide
and inject
in Vue.js to create tightly coupled components, using a VTabs
and VTabPanel
example.
The provide
and inject
functions exposed from the core Vue library are used to allow an ancestor component (higher up the tree) to provide data to all of its descendant components, no matter how deep the component tree is. This is especially useful for building component libraries and creating reusable, interconnected components.
provide
: Used in the provider component to specify the data that should be made available to descendant components.inject
: Used in the consumer components to access the provided data (optionally, it can also define default values, if no ancestor provides the specified data).Let's implement a VTabs
component and VTabPanel
component that can be used like this:
<VTabs>
<VTabPanel title="Tab 1">
<p>Tab 1 Content</p>
</VTabPanel>
<VTabPanel title="Tab 2">
<p>Tab 2 Content</p>
</VTabPanel>
<VTabPanel title="Tab 3">
<p>Tab 3 Content</p>
</VTabPanel>
</VTabs>
in order to produce the following result:
The beauty of this approach is that we can keep up with the state for the active tab in one place (Vtabs
) ensuring only 1 tab is ever open at a time. Plus we have complete control over the content that goes in each tab (utilizing slots) and the tab names are collocated with the tab content (we don’t have define the tab names as a prop to VTabs
and then map that to each VTabPanel
). Put simply, the API for our component is extremely developer friendly.
For our scenario the vTabs
component should provide the context for its child VTabPanel
components. This allows the VTabelPanel
to share the state that determines which tab is displayed to all the panels at once. It also allows sharing any other data that’s convenient to share.
<script setup lang="ts">
import { provide, ref, readonly } from "vue";
// Here we provide a single spot to register all the tabs that should display
// This way we can loop over them and display the clickable tab for each
// It's empty at first because we're letting the child `vTabPanel`s
// determine the tab titles (based on their "title" prop)
const tabs = ref<string[]>([]);
// This is the state that keeps up with the single active tab
// Only one should ever be open at a time
// We'll determine which should be open by
// setting the title of the active tab here
const activeTab = ref<string>();
// This is a helper function for setting the active tab.
// It's important to wrap it in a function if we want to
// allow the child `vTabPanel` to set an active tab
// Why? Because it's recommended in the Vue docs
// to keep direct mutations of your provided state
// to the component that state is defined in (the parent).
// If you want to allow a child to mutate you should provide a function.
// This keeps the control of the state with the parent
// only allowing the child to mutate the state in the allowed way
function activateTab(title: string) {
activeTab.value = title;
}
// This function will allow the child `vTabPanels` to register their title
// with the parent `vTabs`
// Again it's a function because of the reasoning above.
function registerTab(title: string) {
if (tabs.value.includes(title)) return;
tabs.value.push(title);
}
// This is where the magic happens.
// The provide function exposes the data to the child
// The injection key is a unique identifier so that we can
// "pickup" the data in the child using the same key
provide(injectionKey, {
// This is just a good way for us to check in the child that the `vTabPanel`
// was correctly used in the context of the `vTabs` component
withinTabs: true,
// We expose the 2 functions defined above to the child
registerTab,
activateTab,
// We expose the active tab to the child
// but notice we use readonly to keep the child from directly mutating it
activeTab: readonly(activeTab),
});
</script>
<!--
We define the injectionKey in a separate script tag
This makes it possible to export the injection key
to import in the child component (vTabPanel)
(We can't export from inside the script setup tag)
-->
<script lang="ts">
import type { InjectionKey, Ref } from "vue";
export const injectionKey = Symbol("vTabs") as InjectionKey<{
withinTabs: boolean;
registerTab: (title: string) => void;
activeTab: Readonly<Ref<string | undefined>>;
activateTab: (title: string) => void;
}>;
</script>
<!--
In the template, we display a button for each tab (the UI element the user clicks on to change the active tab.
And then we define a slot to display the content for all the panels in
-->
<template>
<div class="tabs">
<div class="tab-trigger-wrapper">
<button
v-for="tab in tabs"
:key="tab"
class="tab-trigger"
:class="{
active: activeTab === tab,
}"
@click="activateTab(tab)"
>
{{ tab }}
</button>
</div>
<div class="tab-content-wrapper">
<slot></slot>
</div>
</div>
</template>
The VTabPanel
component will inject the data provided from the parent and use it to:
title
as a tab with the parentvTabs
component (if not, throw an error because a tab panel doesn’t make sense outside of vTabs
)activeTab
state<script setup lang="ts">
import { inject, ref, readonly, computed } from "vue";
// Notice that import the injection key from the `vTabs` component
// since it's a symbol we can be absolutely certain it's unique
// and since these are tightly coupled it makes sense to get it from the parent
import { injectionKey } from "./vTabs.vue";
// This is a simple title prop
const props = defineProps<{
title: string
}>();
// This is where the magic happens
// Here we "pick up" the data provided by the parent
// we also give some defaults with the same types as the provided
// to make TypeScript happy and keep our IDE autocompleting things correctly
const tabsProvider = inject(injectionKey, {
withinTabs: false,
registerTab: (title: string) => {},
activeTab: readonly(ref()),
activateTab: (title: string) => {},
});
// If withinTabs is false, then the injected data wasn't provided
// Why? because withinTabs defaults to false
// So we're outside the context of `vTabs` which is not a valid use of the panel component
if (!tabsProvider.withinTabs) {
throw new Error("vTab must be used within a vTabs component");
}
// Here we push our panels title to the parent so that it can display the tabs properly
tabsProvider.registerTab(props.title);
// If there is no active tab set, go ahead and set it
// This will only ever be true for the first panel
// Meaning, the first panel will always be the default active one
if (!tabsProvider.activeTab.value) {
tabsProvider.activateTab(props.title);
}
// Finally just check to see if this panel should be active
// based on the active `activeTab` state from the parent
const isActive = computed(() => tabsProvider.activeTab.value === props.title);
</script>
<!--- For the display, only show the panel content if it's active -->
<template>
<div class="tab-content" v-show="isActive">
<slot></slot>
</div>
</template>
And there you have it! Using provide/inject we were able to provide a pair of components that work tightly together to provide robust functionality via a smooth and intuitive component API.
Here is what the usage would look like again.
<VTabs>
<VTabPanel title="Tab 1">
<p>Tab 1 Content</p>
</VTabPanel>
<VTabPanel title="Tab 2">
<p>Tab 2 Content</p>
</VTabPanel>
<VTabPanel title="Tab 3">
<p>Tab 3 Content</p>
</VTabPanel>
</VTabs>
As with all design choices in code, there are always both benefits and drawbacks. Before wrapping up, let’s briefly mention each for this component design strategy.
Using provide
and inject
in Vue.js allows you to create tightly coupled components that can share state and behavior. This pattern is particularly useful for creating reusable UI components. While there are benefits to this approach, it’s important to consider the trade-offs and ensure that the tight coupling is justified by the use case. With careful design, provide
and inject
can help you build robust and maintainable Vue.js applications.
BTW, if you liked this article, you’ll love our course Crafting a Custom Component Library with Vue and Daisy UI. In it we build a UI library from scratch using Daisy UI to provide the aesthetics and to give the library first rate Tailwind CSS support. During the course you can solidify your grasp of this component design strategy but also learn other strategies as you build real world components from scratch.
Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.