Home / Blog / Tightly Coupled Components Vue Components with Provide/Inject
Tightly Coupled Components Vue Components with Provide/Inject

Tightly Coupled Components Vue Components with Provide/Inject

Daniel Kelly
Daniel Kelly
July 15th 2024

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.

What are Provide and Inject?

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

Example Use Case: vTabs and vTabPanel Component

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:

tabs-component-preview.gif

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.

The vTabs Component Definition (Where we Provide the Data)

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 (Where We Inject the Provided Data

The VTabPanel component will inject the data provided from the parent and use it to:

  • Register it’s title as a tab with the parent
  • Check if it’s being used within the vTabs component (if not, throw an error because a tab panel doesn’t make sense outside of vTabs)
  • Activate the first tab on load
  • and conditionally show the tabs content based on the 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>

Using the Tightly Coupled Vue Components

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>

Benefits and Drawbacks of Tightly Coupled Components

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.

Benefits:

  • Encapsulation: Provides a way to encapsulate related functionality within a set of components.
  • Reusability: Can create a reusable set of components that can be used across different parts of the application. They can be used over and over again in isolation as they don’t rely on shared global state.
  • Separation of Concerns: Allows the parent component to manage state and behavior, while the child components focus on their specific rendering logic.
  • Intuitive DX: Allows an API that is intuitive and easy to use

Drawbacks:

  • Tight Coupling: Components become tightly coupled, which can make them harder to reuse independently. In most use cases this isn’t an issue as you ONLY want the child component to be used in the context of the parent.
  • Testing: Testing tightly coupled components can be more complex due to their dependencies. My suggestion is to test them together as they would be used in real life. Don’t try to test each one in isolation.

Tightly Coupled Components with Provide/Inject Conclusion

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.

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

7 Beautiful Next-Level Button Components with Vue, VueUse, and TailwindCSS

7 Beautiful Next-Level Button Components with Vue, VueUse, and TailwindCSS

Combine Vue, VueUse, and TailwindCSS for some practical and beautiful buttons
Daniel Kelly
Daniel Kelly
Unlocking the Power of AI in Your Vue Coding Workflow

Unlocking the Power of AI in Your Vue Coding Workflow

Explore how AI is transforming software development, acting as a valuable coding companion to streamline workflows and overcome challenges. This article delves into practical strategies for effectively integrating AI tools, offering insights for developers at any skill level.
David Robertson
David Robertson

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!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.