Design patterns are incredibly useful in writing code that works well over the long run. They let us use proven solutions to problems that basically every single app has.
But there isn’t a lot written about how design patterns apply specifically to Vue.
And there isn’t a lot out there on patterns that only exist in Vue because of its particular features.
I want to change that.
In this article, I’ve compiled 12 different design patterns for Vue, along with short and concise examples of how they work. This is meant as an overview and a jumping off point, not the end of your exploration. Understanding and applying these patterns in the right places, in the right way, requires a lot more explanation than I can fit into an article like this!
Oh, and even though this is a long article, at the end I’ve included nine bonus patterns.
The simplest solution to lots of state management problems is to use a composable to create a shareable data store.
This pattern has a few parts:
Here's a simple example:
import { reactive, toRefs, readonly } from 'vue';import { themes } from './utils';// 1. Create global state in module scope, shared every// time we use this composableconst state = reactive({darkMode: false,sidebarCollapsed: false,// 2. This theme value is kept private to this composabletheme: 'nord',});export default () => {// 2. Expose only some of the state// Using toRefs allows us to share individual valuesconst { darkMode, sidebarCollapsed } = toRefs(state);// 3. Modify our underlying stateconst changeTheme = (newTheme) => {if (themes.includes(newTheme)) {// Only update if it's a valid themestate.theme = newTheme;}}return {// 2. Only return some of the statedarkMode,sidebarCollapsed,// 2. Only expose a readonly version of statetheme: readonly(state.theme),// 3. We return a method to modify underlying statechangeTheme,}}
Thin composables introduce an additional layer of abstraction, separating the reactivity management from the core business logic. Here we use plain JavaScript or TypeScript for business logic, represented as pure functions, with a thin layer of reactivity on top.
import { ref, watch } from 'vue';import { convertToFahrenheit } from './temperatureConversion';export function useTemperatureConverter(celsiusRef: Ref<number>) {const fahrenheit = ref(0);watch(celsiusRef, (newCelsius) => {// Actual logic is contained within a pure functionfahrenheit.value = convertToFahrenheit(newCelsius);});return { fahrenheit };}
Humble Components are designed for simplicity, focusing on presentation and user input, keeping business logic elsewhere.
Following the "Props down, events up" principle, these components ensure clear, predictable data flow, making them easy to reuse, test, and maintain.
<template><div class="max-w-sm rounded overflow-hidden shadow-lg"><img class="w-full" :src="userData.image" alt="User Image" /><div class="px-6 py-4"><div class="font-bold text-xl mb-2">{{ userData.name }}</div><p class="text-gray-700 text-base">{{ userData.bio }}</p></div><div class="px-6 pt-4 pb-2"><button@click="emitEditProfile"class="bg-blue-500 hover:bg-blue-700 text-whitefont-bold py-2 px-4 rounded">Edit Profile</button></div></div></template><script setup>defineProps({userData: Object,});const emitEditProfile = () => {emit('edit-profile');};</script>
To simplify templates with multiple conditional branches, we extract each branch's content into separate components. This improves readability and maintainability of the code.
<!-- Before --><template><div v-if="condition"><!-- Lots of code here for the true condition --></div><div v-else><!-- Lots of other code for the false condition --></div></template><!-- After --><template><TrueConditionComponent v-if="condition" /><FalseConditionComponent v-else /></template>
You can read a full article on this pattern here.
Extracting logic into composables, even for single-use cases. Composables simplify components, making them easier to understand and maintain.
They also facilitate adding related methods and state, such as undo and redo features. This helps us keep logic separate from UI.
import { ref, watch } from 'vue';export function useExampleLogic(initialValue: number) {const count = ref(initialValue);const increment = () => {count.value++;};const decrement = () => {count.value--;};watch(count, (newValue, oldValue) => {console.log(`Count changed from ${oldValue} to ${newValue}`);});return { count, increment, decrement };}
<template><div class="flex flex-col items-center justify-center"><button@click="decrement"class="bg-blue-500 text-white p-2 rounded">Decrement</button><p class="text-lg my-4">Count: {{ count }}</p><button@click="increment"class="bg-green-500 text-white p-2 rounded">Increment</button></div></template><script setup lang="ts">import { useExampleLogic } from './useExampleLogic';const { count, increment, decrement } = useExampleLogic(0);</script>
Large lists in components can lead to cluttered and unwieldy templates. The solution is to abstract the v-for loop logic into a child component.
This simplifies the parent and encapsulates the iteration logic in a dedicated list component, keeping things nice and tidy.
<!-- Before: Direct v-for in the parent component --><template><div v-for="item in list" :key="item.id"><!-- Lots of code specific to each item --></div></template><!-- After: Abstracting v-for into a child component --><template><NewComponentList :list="list" /></template>
Passing an entire object to a component instead of individual props simplifies components and future-proofs them.
However, this approach can create dependencies on the object's structure, so it's less suitable for generic components.
<!-- Using the whole object --><template><CustomerDisplay :customer="activeCustomer" /></template><!-- CustomerDisplay.vue --><template><div><p>Name: {{ customer.name }}</p><p>Age: {{ customer.age }}</p><p>Address: {{ customer.address }}</p></div></template>
Controller Components in Vue bridge the gap between UI (Humble Components) and business logic (composables).
They manage the state and interactions, orchestrating the overall behavior of the application.
<!-- TaskController.vue --><script setup>import useTasks from './composables/useTasks';// Composables contain the business logicconst { tasks, addTask, removeTask } = useTasks();</script><template><!-- Humble Components provide the UI --><TaskInput @add-task="addTask" /><TaskList :tasks="tasks" @remove-task="removeTask" /></template>
The Strategy Pattern is ideal for handling complex conditional logic in Vue applications.
It allows dynamic switching between different components based on runtime conditions, which improves readability and flexibility.
<template><component :is="currentComponent" /></template><script setup>import { computed } from 'vue';import ComponentOne from './ComponentOne.vue';import ComponentTwo from './ComponentTwo.vue';import ComponentThree from './ComponentThree.vue';const props = defineProps({conditionType: String,});const currentComponent = computed(() => {switch (props.conditionType) {case 'one':return ComponentOne;case 'two':return ComponentTwo;case 'three':return ComponentThree;default:return DefaultComponent;}});</script>
The Hidden Components Pattern involves splitting a complex component into smaller, more focused ones based on how it's used.
If different sets of properties are used together exclusively, it indicates potential for component division.
<!-- Before Refactoring --><template><!-- Really a "Chart" component --><DataDisplay:chart-data="data":chart-options="chartOptions"/><!-- Actually a "Table" component --><DataDisplay:table-data="data":table-settings="tableSettings"/></template><!-- After Refactoring --><template><Chart :data="data" :options="chartOptions" /><table :data="data" :settings="tableSettings" /></template>
I’ve written about this one in a lot more detail here.
The Insider Trading pattern solves the issue of overly coupled parent-child components in Vue. We simplify by inlining child components into their parent when necessary.
This process can lead to a more coherent and less fragmented component structure.
<!-- ParentComponent.vue --><template><div><!-- This component uses everything from the parent.So what purpose is it serving? --><ChildComponent:user-name="userName":email-address="emailAddress":phone-number="phoneNumber"@user-update="(val) => $emit('user-update', val)"@email-update="(val) => $emit('email-update', val)"@phone-update="(val) => $emit('phone-update', val)"/></div></template><script setup>defineProps({userName: String,emailAddress: String,phoneNumber: String,});defineEmits(['user-update', 'email-update', 'phone-update']);</script>
What's "too long" when it comes to components?
It's when it becomes too hard to understand.
The Long Components Principle encourages the creation of self-documenting, clearly named components, improving the overall code quality and understanding.
<!-- Before: A lengthy and complex component --><template><div><!-- Lots of HTML and logic --></div></template><!-- After: Breaking down into smaller componentswhere the name tells you what the code does. --><template><ComponentPartOne /><ComponentPartTwo /></template>
I’ve got nine more design patterns for you, but I’ve run out of space here and this article is already getting quite long. If you want to learn these, they are all included in the Clean Components Toolkit in great detail.
Three of these patterns were just added in June of 2024, so they’re brand new!
Here they are:
In the Clean Components Toolkit, each pattern has:
And it’s 35% off for the next few days! (Until June 8th)
If you’re interested in deepening your Vue skills through design patterns, be sure to check it out here. You can also see a preview of the toolkit by going here and logging in with your Github account.