Even though components help make code reusable and organized, the crux of the issue isn’t addressed if the components themselves are not lean.
The problem
Let’s assume you have a <Post /> component that displays a social-media-like post. This component should contain most of the following or more:
- The poster’s avatar, display name, and handle
- A context menu for the post actions such as blocking the user, requesting to not recommend similar posts, etc.
- The post’s textual body
- Any attachments or embeds
- Post statistics like reactions, comments, reposts, etc.
- Post actions such as reacting, commenting, reposting, sharing, etc.
The list goes on and on when you want to handle reposts, quote posts, or show headlines such as $username liked this for follower-based engagement.
As a single component, <Post /> is filled with a bunch of v-ifs, and a lot of lines of refs and computeds in its <script>. When you amount for actions that trigger modals, it’s great if you can manage to have the component stay below 500 LOC.
The solution: more components
By now you probably already know you can extract blocks inside the <Post /> component into smaller ones. You would start with something like this:
<template> <div> <PostHeader :post /> <PostBody :post /> <PostAttachments :post /> <PostStatistics :post /> <PostActions :post /> </div></template>
<script setup lang="ts">import type { Post } from "~/src/api/contracts"
const { post,} = defineProps<{ post: Post}>()</script>Now let’s say we want the context menu of the post to have a “Show comments” button. To make it controllable in multiple places, we’ll need to use v-model.
<template> <div> <PostHeader :post v-model:is-comments-open="isCommentsOpen" /> <PostBody :post /> <PostAttachments :post /> <PostStatistics :post v-model:is-comments-open="isCommentsOpen" /> <PostActions :post v-model:is-comments-open="isCommentsOpen" /> </div></template>
<script setup lang="ts">import type { Post } from "~/src/api/contracts"
const { post,} = defineProps<{ post: Post}>()
const isCommentsOpen = ref(false)</script>This is already starting to clutter up. To make things worse, let’s inspect how <PostHeader /> would look like:
<template> <div class="flex justify-between"> <PostUser :post /> <PostContextMenu v-model:is-comments-open="isCommentsOpen" /> </div></template>
<script setup lang="ts">import type { Post } from "~/src/api/contracts"
const isCommentsOpen = defineModel("isCommentsOpen", { required: true })
const { post,} = defineProps<{ post: Post}>()</script>New problem: prop and model drilling
The more we atomize components, the more we need to make sure to pass props and sync state. Although it’s still far more maintainable than if everything were inside <Post />, writing components this way is very unpleasant, and creates a lot of duplication in Vue’s reactivity. Still, in fact, we’re only missing one last Vue ingredient.
provide/inject
Vue’s provide and inject functions are exactly what we need to solve this issue, but we’ll not be using them directly inside <Post />.
Instead, we’ll create two composables: useProvidePostContext() and usePostContext().
We’ll use the vueuse’s createInjectionState() function to make this even easier:
import { createInjectionState } from "@vueuse/core"import type { Post } from "~/src/api/contracts"
const [useProvidingState, useInjectedState] = createInjectionState(( post: MaybeRefOrGetter<Post>) => { const postComputed = computed(() => toValue(post))
const isCommentsOpen = ref(false)
return { post: postComputed. isCommentsOpen, }})
export function useProvidePostContext( post: MaybeRefOrGetter<Post>) { return useProvidingState(post)}
export function usePostContext() { const state = useInjectedState()
if (!state) { throw new Error("No post context found. Did you call useProvidePostContext() on a parent component?") }
return state}All of our components now become far simpler:
<template> <div> <PostHeader /> <PostBody /> <PostAttachments /> <PostStatistics /> <PostActions /> </div></template>
<script setup lang="ts">import { useProvidePostContext } from "~/composables/context/post"import type { Post } from "~/src/api/contracts"
const { post,} = defineProps<{ post: Post}>()
// Important to pass the post as a getter for reactivityuseProvidePostContext(() => post)</script>Notice how our <PostHeader /> will now require nothing in its script.
<template> <div class="flex justify-between"> <PostUser /> <PostContextMenu /> </div></template>
<script setup lang="ts">
</script><PostContextMenu /> can access the context and start altering state.
<template> <ContextMenu :items="contextMenuItems" /></template>
<script setup lang="ts">import { usePostContext } from "~/composables/context/post"
const { isCommentsOpen,} = usePostContext()
const contextMenuItems = computed(() => { return [ { label: "Open comments", action: () => isCommentsOpen.value = true } ]})</script>With a decoupled context, the components focus more on the display logic, leaving less room for bugs when there’s no state passed around the components.