12 Design Patterns in Vue

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.

1. The Data Store Pattern

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:

  1. A global state singleton
  2. Exporting some or all of this state
  3. Methods to access and modify the state

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 composable
const state = reactive({
darkMode: false,
sidebarCollapsed: false,
// 2. This theme value is kept private to this composable
theme: 'nord',
});
export default () => {
// 2. Expose only some of the state
// Using toRefs allows us to share individual values
const { darkMode, sidebarCollapsed } = toRefs(state);
// 3. Modify our underlying state
const changeTheme = (newTheme) => {
if (themes.includes(newTheme)) {
// Only update if it's a valid theme
state.theme = newTheme;
}
}
return {
// 2. Only return some of the state
darkMode,
sidebarCollapsed,
// 2. Only expose a readonly version of state
theme: readonly(state.theme),
// 3. We return a method to modify underlying state
changeTheme,
}
}

2. Thin Composables

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 function
fahrenheit.value = convertToFahrenheit(newCelsius);
});
return { fahrenheit };
}

3. Humble Components Pattern

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-white
font-bold py-2 px-4 rounded"
>
Edit Profile
</button>
</div>
</div>
</template>
<script setup>
defineProps({
userData: Object,
});
const emitEditProfile = () => {
emit('edit-profile');
};
</script>

4. Extract Conditional

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.

5. Extract Composable

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>

6. List Component Pattern

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>

7. Preserve Object Pattern

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>

8. Controller Components

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 logic
const { tasks, addTask, removeTask } = useTasks();
</script>
<template>
<!-- Humble Components provide the UI -->
<TaskInput @add-task="addTask" />
<TaskList :tasks="tasks" @remove-task="removeTask" />
</template>

9. Strategy Pattern

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>

10. Hidden Components

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.

11. Insider Trading

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>

12. Long Components

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 components
where the name tells you what the code does. -->
<template>
<ComponentPartOne />
<ComponentPartTwo />
</template>

9 More Patterns for You

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:

  1. Inline Composables (new!) — Learn how to refactor incrementally and keep your app in a good state. You can read about them in 3 Ways to Create Inline Composables.
  2. Layers of abstraction (new!) — Learn the underlying principle that makes not-quite-mvc work so well.
  3. Lightweight State Management (new!) — Learn a simple and straightforward alternative to the Data Store
  4. Not Quite MVC Principle — Learn how to apply the best parts of MVC to your Vue application.
  5. Props Down, Events Up Principle — Learn the most important principle of state management and its implications.
  6. Lifting State Pattern — Learn how to pass state to cousin components that are far apart in the component tree.
  7. Combine Branches Pattern — Learn how to simplify complex conditional logic by combining branches.
  8. Component Boundaries Pattern — Learn the most powerful way to break up components — by leveraging pre-existing boundaries.
  9. Item Component Pattern — Learn how to extract loops into simpler, single components.

In the Clean Components Toolkit, each pattern has:

  • An overview that discusses the pattern, it’s pros, cons, and any edge cases to consider
  • A step-by-step refactoring worked out on a real-world code example, so you can see exactly how to apply the pattern to your code
  • A quiz to help you solidify your understanding
  • A video that discusses the pattern in more depth, by walking through the quiz

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.