DEV Community

Cover image for Exploring advanced design patterns in Vue.js
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Exploring advanced design patterns in Vue.js

Written by Rahul Chhodde✏️

Design patterns are reliable solutions to common problems software developers face when building software systems. They’re not specific to any programming language or framework but serve as general tools for solving problems.

In frontend development, design patterns can provide reusable solutions to recurring problems in building UIs and web apps. In this article, we will explore some popular design patterns used in Vue.js and provide clear examples to illustrate their practical applications.

Jump ahead:

To get the most out of this guide, it’s best if you have an intermediate understanding of JavaScript and some basic familiarity with Vue.

Why use design patterns in Vue.js?

As you may already know, Vue is a component-based framework, and the concept of components is a design pattern in itself. Components serve as self-contained units of code that can be reused across an application, promoting modularity and maintainability.

In addition to its component-based structure, Vue leverages the concept of reactive data binding. This means that changes to data within a component are automatically reflected in the user interface, resulting in more concise and readable code.

Furthermore, Vue offers support for custom directives, which can be attached as JavaScript functions to HTML elements to add custom functionality. This flexibility also allows you to implement various other design patterns, including the observer pattern, the state management pattern, and more.

Before we delve into the specifics of these design patterns, let’s evaluate and explore areas where they’re most needed and can potentially yield the greatest improvements in general.

Design patterns in Vue can:

  • Offer a structured approach to solving problems: When you face familiar problems, applying the appropriate pattern reduces the likelihood of errors, eliminating the need for custom solutions
  • Enhance code reusability: Instead of reinventing the wheel, you can apply these patterns to decouple various system components. This helps simplify the process of modifying and extending the system in the future
  • Improve communication: Establishing a common vocabulary through design patterns creates a shared language with other developers that can simplify collaboration and make it easier to understand each other‘s codebases
  • Promote uniform coding practices: Following established design patterns helps ensure that your codebase maintains a consistent style and structure, even when multiple developers contribute to a project
  • Improve scalability: Design patterns simplify distribution and management, which can help manage complexity and ensure adaptability as your project grows and requirements change
  • Enhance maintenance: Code structured with design patterns is often more maintainable. You can refer to established patterns when addressing issues or making changes, avoiding the need to create custom, complex, ad-hoc solutions that can be difficult to understand and maintain
  • Elevate code quality: Enforcing or encouraging best practices through design patterns — like encapsulation, abstraction, and polymorphism — results in code that is more readable, maintainable, and extensible
  • Serve as a form of built-in documentation: Using design patterns means that anyone reviewing your code can recognize the patterns you use. This allows them to gain insights into your system’s architecture and behavior, even if there’s limited accompanying documentation

These benefits can save developers significant time and effort, highlighting the critical role of design patterns in software engineering and development. Let's move on to some advanced patterns that you can use in Vue to solve different problems.

Categories of design patterns

Design patterns in programming primarily revolve around objects and their interactions within a software system. They’re divided into three main categories based on how they deal with objects:

  • Creational patterns deal with how we instantiate objects. They ensure that a system is independent of specific classes and objects in the system, making the process of creating objects more flexible and efficient
  • Structural patterns focus on how we can combine objects and classes to form larger structures that are still flexible and efficient. These patterns, along with behavioral patterns, help simplify the system’s structure and make it more understandable
  • Behavioral patterns are concerned with how objects communicate, interact, and work with each other to complete tasks or achieve different behaviors and goals, making the system more flexible and understandable

In the upcoming sections, we will explore some advanced design patterns from each of the categories discussed above and how you can utilize them to improve the structure and effectiveness of your Vue applications.

Builder pattern

The builder pattern is a creational design pattern. You can use it to construct complex objects step-by-step in a more readable and maintainable manner.

This pattern separates an object’s construction from its representation. As a result, you can create various configurations of it while maintaining consistency in the construction process.

For instance, in Vue, you can apply the builder pattern to dynamically create form elements using a FormBuilder class. This is particularly useful when dealing with forms that have numerous fields and require a streamlined approach to create them.

Here is a simplified example of how you can implement a basic builder pattern in Vue to create and configure an object for different form fields with the help of a builder class:

// FormBuilder.js
class FormBuilder {
  constructor() {
    this.formFields = [];
  }
  addTextField(name, label, placeholder = '') {
    this.formFields.push({
      type: 'text',
      name,
      label,
      placeholder,
    });
    return this; // Return the builder instance for method chaining
  }
  addNumberField(...) {
    // Add a number field
  }
  addSelectField(...) {
    // Add a select field
  }
  build() {
    return this.formFields;
  }
}
export default FormBuilder;
Enter fullscreen mode Exit fullscreen mode

Following the builder pattern, we usually append the suffix Builder to our builder classes, as we did with the FormBuilder class in the code above.

The FormBuilder class provides methods for adding different attributes to the form fields, each with its respective properties such as name, label, and options. The build method returns an array of form field definitions, which we can then use to generate the form in a Vue component.

Now, let’s apply the FormBuilder class by importing it into a Vue component, which we’ll name Form.vue. We’ll utilize the methods of the builder class to add different form fields to an initially empty object — in this case, formFields — in a chained manner:

<!-- Form.vue -->
<script>
import FormBuilder from './helpers/FormBuilder';

export default {
  data() {
    return {
      formFields: [],
      formData: {}, // Add a data property to store the form data
    };
  },
  created() {
    /* 
     * Use the FormBuilder class and its methods 
     * to construct the `formFields` object array 
     * with different form fields.
     */
    this.formFields = new FormBuilder()
      .addTextField('name', 'Name')
      .addNumberField(...)
      .addSelectField(...)
      .build();
  },
  methods: {
    handleSubmit() {
      // Log the form data when the form is submitted
      console.log(this.formData);
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

At the end of this chain, we use the build() method to return the populated formFields object. We can then use this object in the template section of our Form.vue component. Here’s some sample code to demonstrate that implementation:

<template>
  <form>
    <div class="input-row" v-for="(field, index) in formFields" :key="index">
      <label :for="field.name">{{ field.label }}</label>
      <!-- 
        Use the `input` tag if it's 
        a text or a number input.
      -->
      <div class="input-field">
        <input
          v-if="field.type === 'text' || field.type === 'number'"
          :name="field.name"
          :type="field.type"
          :id="field.name"
        />
        <!--
          Use the `select` tag and loop through 
          its options if it's a select input. 
        -->
        <select v-else :name="field.name" :id="field.name">
          <option
            v-for="(option, optIndex) in field.options"
            :key="optIndex"
            :value="option.value"
          >
            {{ option.label }}
          </option>
        </select>
      </div>
    </div>
    <div class="input-row">
      <button type="submit">Submit</button>
    </div>
  </form>
</template>
Enter fullscreen mode Exit fullscreen mode

That’s all there is to it! You can incorporate additional field methods and options into the FormBuilder class as required. Below is a working demonstration of the same concept, decorated with some fancy CSS styles: Three Forms Created Using Builder Design Pattern In Vue You can interact with the demo directly on StackBlitz.

Adapter pattern

The adapter pattern is a structural design pattern that is often used to make two incompatible interfaces work together. This pattern is useful when you need to integrate with external libraries or APIs that have different interfaces.

For example, imagine you have an external API that provides random user data in a different format than your Vue component expects. You can use the adapter pattern to transform the data so your component can use it.

To accomplish that, you would create an adapter class that takes the external data and transforms it into the expected format for your component. The adapter would have a dedicated method — generally called adapt — that performs the transformation:

// UserAdapter.js
class UserAdapter {
  constructor(externalUserData) {
    this.externalUserData = externalUserData;
  }
  adapt() {
    /*
     * Adapt the external user data to match
     * the component's expected format.
     *
     * Considering the structure of the expected
     * response, grab the right object array
     * (`results`) and its only value.
     *
     * @link https://randomuser.me/api/
     */
    const userData = this.externalUserData.results[0];
    return {
      name: `${userData.name.first} ${userData.name.last}`,
      email: userData.email,
      gender: userData.gender,
      location: `${userData.location.city}, ${userData.location.country}`,
      displayPic: userData.picture.large,
    };
  }
}
export default UserAdapter;
Enter fullscreen mode Exit fullscreen mode

We can now include the name, email, gender, and other properties returned by the adapt method from the UserAdapter class in our component’s markup, as shown in the below code:

<template>
  <div v-if="user">
    <h1>{{ name }}</h1>
    <p>{{ location }}</p>
    <p>{{ email }}</p>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

The user object in the above code will be created in the script section of our component, where we can import the UserAdapter class and use its capabilities to assemble the data for our component:

<script>
import UserAdapter from './UserAdapter';

export default {
  data() {
    return {
      userAdapter: null,
      user: null
    };
  },
  created() {
    /*
     * Get user data from an external API 
     * with a different format.
     */
    async created() {
      try {
        // Make an API call
        const response = await fetch('...');
        if (!response.ok) {
          throw new Error('Failed to fetch external user data');
        }
        const externalUserData = await response.json();

        /*
         * Create an adapter to convert external weather data 
         * to the format expected by the component.
         */
        this.userAdapter = new UserAdapter(externalUserData);

        // Adapt the data to the component's expected format
        this.user = this.userAdapter.adapt();
      } catch (error) {
        console.error('Error fetching external user data:', error);
        this.errorMessage = 'Failed to load user data. Please try again later.';
      }
    },
  }
};
</script>
Enter fullscreen mode Exit fullscreen mode

In the example above, I'm fetching some dummy user data using a free API. You can later replace it with your own API call as needed. Here's what a quick implementation of the code shared above could look like: Demo Of Vue Adapter Pattern Showing Left Panel With Code And Right Panel With Random User Card Being Generated On Reload Of Page You can fork this example on StackBlitz to customize and experiment with it.

Provider pattern

The provider pattern is a creational design pattern that allows you to provide data or functionality from a parent component to its descendant without the need for explicit prop drilling. It uses the inbuilt provide and inject methods in Vue to simplify the task of injecting dependencies.

In the example below, we show how to implement the provider pattern in Vue by creating a provider component ParentComponent.vue and a child component Quote.vue. When using this pattern, it’s common to postfix such components with Provider and an indication of the type of data they provide.

QuoteProvider injects data from itself into child components. Within this component, we define a title variable, initially set with a default value. Additionally, we introduce changeTitle function to allow for later modification of this title.

To facilitate sharing this title variable with child components, we utilize Vue’s provide function.

See the code below:

<!-- ParentComponent.vue -->
<template>
  <div>
    <ChildComponent />
    <button @click="changeTitle">Inject a new title</button>
  </div>
</template>
<script>
import { ref, provide } from 'vue'; // Import the 'provide' function
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent,
  },
  setup() {
    const title = ref(
      'A string sent to `ChildComponent.vue` from `ParentComponent.vue`'
    );

    function changeTitle() {
      title.value = 'Another string called from `ParentComponent.vue` in `ChildComponent.vue`';
    }

    // Provide the title to ChildComponent outside the setup function
    provide('title', title);

    return {
      title,
      changeTitle,
    };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

Quote.vue — the child component for this example — employs the inject function to access the title variable provided by its parent QuoteProvider. This enables the child component to display the title without the need for explicit prop passing or direct communication with the parent:

<template>
  <div>
    <h2>{{ providedTitle }}</h2>
  </div>
</template>
<script>
import { inject } from 'vue';

export default {
  setup() {
    // Inject the provided title from the parent component
    const providedTitle = inject('title');

    return {
      providedTitle,
    };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

When implementing the QuoteProvider, clicking the button responsible for changing the title triggers the changeTitle function to update the title value. The child component automatically reflects this change, establishing it as a reactive and efficient method to share data between components.

The provider pattern is especially useful in Vue when dealing with deeply nested components that need access to the same data or functionality provided by a higher-level ancestor. Here’s an example of this design pattern in action: Demo Of Provider Design Pattern In Vue Showing Text Explanation Of Demo, Quote Displayed In Large, Bold Text, And Button To Inject New Title. User Shown Clicking Button And Generating New Title You can check out the live demo on StackBlitz to fork or interact with it.

Container pattern

The container pattern is a creational design pattern that focuses on creating and managing objects. It is often used to manage the creation and lifetime of objects, especially when there are complex dependencies between these objects.

This pattern abstracts the process of object creation and hides the details of how objects are instantiated and wired together. It helps enforce the separation of concerns, a SOLID principle that promotes better maintainability, testability, and flexibility in your code.

Let’s demonstrate how to use the container pattern by creating presentation components that are responsible for rendering the UI and displaying data. They receive data and events from their parent container component but don’t directly manipulate the data themselves.

The TaskList component we set up below is responsible for displaying a list of tasks and their completion status. It carries a tasks array as a prop and emits an event when a task is completed:

<template>
  <ul>
    <li v-for="(task, index) in tasks" :key="index">
      <input type="checkbox" v-model="task.completed" @change="completeTask(index)" />
      {{ task.text }}
    </li>
  </ul>
</template>

<script>
export default {
  props: ['tasks'],
  methods: {
    completeTask(index) {
      this.$emit('completeTask', index);
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

Similarly, we can create a TaskForm component that displays an input field for adding tasks and emits an event when a new task should be added:

<template>
  <div>
    <input
      class="add-task-input"
      v-model="task"
      @keyup.enter="addTask"
      placeholder="Add a task"
    />
  </div>
</template>
<script>
export default {
  data() {
    return {
      task: '',
    };
  },
  methods: {
    addTask() {
      if (this.task.trim() === '') return;
      this.$emit('addTask', this.task);
      this.task = '';
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

Now our taskContainer component manages the application state and contains the business logic. This component will interact with data and methods related to that data:

<template>
  <div>
    <h1>Task List</h1>
    <TaskForm @addTask="addTask" />
    <TaskList :tasks="tasks" @completeTask="completeTask" />
  </div>
</template>

<script>
import TaskForm from './TaskForm.vue';
import TaskList from './TaskList.vue';
export default {
  components: {
    TaskForm,
    TaskList,
  },
  data() {
    return {
      tasks: [],
    };
  },
  methods: {
    addTask(task) {
      this.tasks.push({ text: task, completed: false });
    },
    completeTask(index) {
      this.$emit('completeTask', index, !this.tasks[index].completed);
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, taskContainer manages the tasks array and provides the following two methods for adding tasks and marking them as completed:

  • addTask gets invoked when the TaskForm component emits the addTask event. It takes the task as a parameter and then delegates the task addition to the taskContainer by calling taskContainer.addTask(task)
  • completeTask is called when the TaskList component emits the completeTask event, along with an index parameter. It delegates the task completion to the taskContainer by invoking taskContainer.completeTask(index)

Additionally, taskContainer is responsible for monitoring changes in the tasks array and updating the presentation component TaskList when the data changes.

Now, we can simply import our container component into a parent component and see the app in action: Demo Of Container Design Pattern In Vue Showing Task Manager With User Typing New Task Into List And Checking Off An Item On The List Note that the app simply adds tasks through an input box for now to demonstrate the container-presentational pattern. You may use the StackBlitz demo to improve this app and add more features and utilities to experiment with it further.

Composable pattern

The composable pattern is a behavioral design pattern that promotes creating small, functional, reusable, and interchangeable building blocks, often referred to as composables. Composables are highly modular by design — you can combine them to create more complex and customized functionality.

The pattern is particularly prevalent in functional programming paradigms, but you can also apply it in object-oriented programming and other paradigms.

Let’s consider an example of a Vue application with various user settings, including preferences for themes, languages, and notifications. Instead of developing a single, monolithic component tightly coupled to handle all these settings, you can employ the composable pattern to break down this functionality into manageable pieces.

First, we’ll create a module that defines and exports functions and reactive properties associated with user preferences.

With Vue’s ref, you can establish reactive properties for aspects like theming, language selection, turning notifications on and off, and similar settings. By defining the getter and setter functions for these properties, you make them accessible for other parts of the application.

See the code below:

// userPreferences.js
import { ref } from 'vue';

const theme = ref('light');
const language = ref('english');
const notifications = ref(true);

// Getter for theme
const getTheme = () => theme.value;

// Setter for theme
const setTheme = (newTheme) => {
  theme.value = newTheme;
};

// Getter for language
const getLanguage = () => language.value;

// Setter for language
const setLanguage = (newLanguage) => {
  language.value = newLanguage;
};

// Getter for notifications
const getNotificationsEnabled = () => notifications.value;

// Setter for notifications
const setNotificationsEnabled = (enabled) => {
  notifications.value = enabled;
};

export {
  getTheme,
  setTheme,
  getLanguage,
  setLanguage,
  getNotificationsEnabled,
  setNotificationsEnabled,
};
Enter fullscreen mode Exit fullscreen mode

Now, we can proceed to define composable functions to manage the settings separately.

For instance, let’s create a file named themeSelector.js to define useThemeSelection composable function, which encapsulates the logic for handling the selected theme. It utilizes Vue’s computed property to create a reactive property called selectedTheme.

Within selectedTheme, we define get and set methods that leverage the functions from the user preferences module we discussed earlier to both retrieve and update the selected theme. This separation of concerns allows for more modular and maintainable code. See below:

// themeSelector.js
import { computed } from 'vue';
import { getTheme, setTheme } from '../utils/userPreferences';

export function useThemeSelection() {
  const selectedTheme = computed({
    get: () => getTheme(),
    set: (newTheme) => setTheme(newTheme),
  });
  return {
    selectedTheme,
  };
}
Enter fullscreen mode Exit fullscreen mode

Likewise, we can set up composables for all other preferences. These composables can be employed within Vue components responsible for rendering related UI options.

In this case, we have a select dropdown containing theme choices. It utilizes the useThemeSelection composable to handle the selected theme. When there’s a change in the theme dropdown selection, it invokes the handleChange method to update the selected theme:

<template>
  <div>
    <label for="theme-switch">Theme</label>
    <select id="theme-switch" @change="handleChange" v-model="selectedTheme">
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  </div>
</template>
<script>
import { defineComponent } from 'vue';
import { useThemeSelection } from '../composables/themeSelector';

export default defineComponent({
  setup() {
    const { selectedTheme } = useThemeSelection();
    const handleChange = (event) => {
      const newTheme = event.target.value;
      selectedTheme.value = newTheme; // Set the new theme
    };
    return {
      selectedTheme,
      handleChange,
    };
  },
});
</script>
Enter fullscreen mode Exit fullscreen mode

Now, in a parent component, we can import the ThemeSelector component and utilize the useThemeSelection composable:

<template>
  <div class="settings">
    <div class="settings-row">
      <ThemeSelector />
      <div :class="selectedTheme">
        Here's how the {{ selectedTheme }} theme looks like.
      </div>
    </div>
  </div>
</template>

<script>
import { defineComponent } from 'vue';
import { useThemeSelection } from './composables/themeSelector';
import ThemeSelector from './components/ThemeSelector';

export default defineComponent({
  setup() {
    const { selectedTheme } = useThemeSelection();

    return {
      selectedTheme,
    };
  },
  components: {
    ThemeSelector
  },
});
</script>

<style>
.light {
  background-color: #fff;
  color: #000;
}
.dark {
  background-color: #000;
  color: #fff;
}
</style>
Enter fullscreen mode Exit fullscreen mode

As shown in the above code, we have the ThemeSelector component and some text content with a class based on the selected theme. The selectedTheme is passed down to the ThemeSelector component for synchronization.

We also have some styles at the end for the light and dark themes.

Let’s see how it looks: Demo Of Composable Design Pattern In Vue With Various Composables Put Together To Create A List Of User Preferences To Set. User Shown Toggling Various Preference Settings, Including Changing Light To Dark Theme, Changing Language From English To Hindi, Then German, Then Japanese, And Enabling And Disabling Notifications You can interact with the demo directly on StackBlitz.

It’s important to recognize that the composable design pattern is closely associated with the Composition API, one of Vue’s two APIs for component creation.

The Composition API was introduced in Vue 3 to offer enhanced flexibility and reusability when structuring and distributing component logic. It empowers you to craft composable functions that can be effortlessly reused and combined to build complex component behaviors.

Observer pattern

The observer pattern is a behavioral design pattern that you can use to create loosely coupled systems that are easy to maintain and extend. It’s especially useful in applications where you need to notify multiple objects of state changes in a single object.

This pattern is also known as the observable pattern. It allows an object to notify its dependents, or observers, of any state changes. The subject, or observable, maintains a list of its observers and calls their update methods whenever its state changes.

In the context of Vue, the observer pattern is used to manage reactivity and handle dynamic updates in the UI. Let’s see an example that shows a notification every time a checkbox is checked or unchecked.

We’ll start by creating the notification component. Let’s structure the notification and provide it with a base CSS class for basic presentation.

Also, we’ll use the :class directive to dynamically add the active CSS class to the notification element based on the presence of the message data property. This data property acts as the body of our notification, which we will discuss further in a moment.

The active CSS class may contain the visibility directions for the notification element, which can help create a nice animated look and feel for the notification element.

<template>
  <div class="notification" :class="{ active: message }">
    {{ message }}
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Next, in the script section, we use the data method to define some data properties of our notification component.

As mentioned before, this method has a message data property that stores the notification message. It also has a timerId for the timeout functions that enable users to auto-hide notifications:

<script>
const NOTIFICATION_DURATION = 3000;

export default {
  data() {
    return {
      message: null,
      timerId: null,
    };
  },
  methods: {
    showNotification(message) {
      this.message = message;
      if (this.timerId) {
        clearTimeout(this.timerId);
      }
      this.timerId = setTimeout(() => {
        this.hideNotification();
      }, NOTIFICATION_DURATION);
    },
    hideNotification() {
      this.message = null;
      this.timerId = null;
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

The notification component, acting as the observer in this case, carries two methods:

  • showNotification displays the notification. It sets the message data property to the provided message, clears any existing timer if any, and starts a new timer to call the hideNotification method after three seconds
  • hideNotification hides the notification. It sets the message to null, effectively hiding the notification message, and clears the time by setting timerId to null

Let’s now consume the notification component in a parent component and establish the logic for it to work. I’m using the App.vue file to do that, but you may create a separate component if you want.

The parent component includes an input checkbox element, which binds its state to the isChecked data property with the help of the v-model directive. When the checkbox is checked or unchecked, ischecked will reflect its state:

<template>
  <label>
    <input
      type="checkbox"
      v-model="isChecked"
      @change="toggleNotification"
    />
    <span>Show notification</span>
  </label>

  <Notification ref="notification" />
</template>
Enter fullscreen mode Exit fullscreen mode

At last, we referenced the Notification component with notification. This allows the parent, or subject, component to refer to the Notification component when assigning logic in the script section later on. Next, in the script section of the parent component, let’s import the Notification component and register it in the components property:

<script>
import Notification from './Notification.vue';

export default {
  components: {
    Notification,
  },
  data() {
    return {
      isChecked: false,
    };
  },
  methods: {
    showNotification() {
      const message = this.isChecked
        ? 'Checkbox is checked!'
        : 'Checkbox is unchecked!';
      this.$refs.notification.showNotification(message);
    },
    toggleNotification() {
      this.showNotification();
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

You can see our observer pattern demo below or interact with it on CodePen:

See the Pen Observer Design Pattern In Vue.js by Rahul C (@_rahul) on CodePen.

State management pattern

The state management design pattern facilitates the efficient sharing of application state across components while maintaining a clear separation between domain representation and state management. It also falls under the behavioral category.

In Vue, the Vuex framework is a widely adopted solution that also promotes the use of this pattern. Let’s delve into Vuex to illustrate how to implement this design pattern. Note that Vuex is not included by default in Vue, so you must install it as an additional dependency to use it in your Vue app.

Let’s start by creating a Vuex store by defending its structure in the cartStore.js file. This file essentially serves as the central hub for managing the shopping cart’s data and actions:

// cartStore.js
import { createStore } from 'vuex';

const cartStore = createStore({
  state: {
    cartItems: [],
  },
  getters: {
    cartTotal(state) {
      return state.cartItems.reduce((total, item) 
        => total + item.price * item.quantity, 0);
    },
  },
  mutations: {
    addToCart(state, product) {
      const existingItem = state.cartItems.find((item) 
        => item.id === product.id);
      if (existingItem) {
        existingItem.quantity++;
      } else {
        state.cartItems.push({
          ...product,
          quantity: 1
        });
      }
    },
    removeFromCart(state, productId) {
      const index = state.cartItems.findIndex((item) 
        => item.id === productId);
      if (index !== -1) {
        state.cartItems.splice(index, 1);
      }
    },
    clearCart(state) {
      state.cartItems = [];
    },
  },
  actions: {
    addToCart({
      commit
    }, product) {
      commit('addToCart', product);
    },
    removeFromCart({
      commit
    }, productId) {
      commit('removeFromCart', productId);
    },
    clearCart({
      commit
    }) {
      commit('clearCart');
    },
  },
});

export default cartStore;
Enter fullscreen mode Exit fullscreen mode

In the code above, the store structure is created using the createStore function from Vuex. It includes four main sections:

  • state — Initializes the initial state of the store, which is an empty array called cartItems. This array represents the items in the shopping cart
  • getters — Functions that compute and return derived values based on the state. In this example, there's a cartTotal getter that calculates the total cost of the items in the shopping cart
  • mutations — Define how the state can be modified. There are mutations like addToCart, removeFromCart, and clearCart which handle different aspects of modifying the shopping cart state
  • actions — Functions that perform asynchronous operations or complex logic. They typically commit mutations to update the state. In this case, we use actions like addToCart, removeFromCart, and clearCart to interact with the mutations

In the app’s entry point — usually the main.js file — we’ll import the store and register it with Vue using the store option. This makes the store available to all components in your application:

import { createApp } from 'vue';
import App from './App.vue';
import cartStore from './store/cartStore';

createApp(App)
  .use(cartStore) // Using the cartStore
  .mount('#app');
Enter fullscreen mode Exit fullscreen mode

To use the store in your components, import it and use the $store property to access its state, mutations, actions, or getters. Components can read data from the state and commit mutations or dispatch actions to update the state:

<!-- Cart.vue -->
<template>
  <h1>Shopping Cart</h1>
  <div v-if="cartItems.length === 0">Your cart is empty.</div>
  <div class="cart-items" v-else>
    <div v-for="item in cartItems" :key="item.id" class="cart-item-listing">
      <p>{{ item.name }} - ${{ item.price }} (Quantity: {{ item.quantity }})</p>
      <button @click="removeFromCart(item.id)">Remove from Cart</button>
    </div>
    <div class="cart-total">
      <p>Total: ${{ cartTotal }}</p>
      <button @click="clearCart">Clear Cart</button>
    </div>
  </div>
</template>

<script>
import { createStore } from 'vuex';

export default {
  data() {
    return {
      cartItems: [],
    };
  },
  computed: {
    cartTotal() {
      return this.cartItems.reduce(
        (total, item) => total + item.price * item.quantity,
        0
      );
    },
  },
  methods: {
    addToCart(product) {
      const existingItem = this.cartItems.find(
        (item) => item.id === product.id
      );
      if (existingItem) {
        existingItem.quantity++;
      } else {
        this.cartItems.push({
          ...product,
          quantity: 1
        });
      }
    },
    removeFromCart(productId) {
      const index = this.cartItems.findIndex((item) => item.id === productId);
      if (index !== -1) {
        this.cartItems.splice(index, 1);
      }
    },
    clearCart() {
      this.cartItems = [];
    },
  },
  mounted() {
    // You can simulate adding items to the cart on component mount
    this.addToCart({
      id: 1,
      name: 'Photo frame',
      price: 10
    });
    this.addToCart({
      id: 2,
      name: 'Backpack',
      price: 20
    });
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

The methods and properties like cartItems, cartTotal, removeFromCart, and clearCart defined in Cart.vue interact with the Vuex store to update the shopping cart’s state.

Vue’s reactivity ensures that any changes made to the state through mutations or actions will automatically trigger updates in components that depend on that data. This is a key advantage of using Vuex.

Here’s our finished demo in action: Demo Of State Management Design Pattern In Vue Showing Shopping Cart With Items And Prices. User Shown Clicking Button To Remove Items From Cart, Which Updates Cart Price Total. Clicking Button To Clear Cart Results In All Cart Items Getting Cleared And Replaced With Text Describing Empty Cart You can interact with this demo directly on StackBlitz.

Middleware pattern

The middleware design pattern is a behavioral design pattern used to manage requests and responses in a systematic and extensible way. It does so by inserting processing steps or filters between the sender and receiver.

If you are acquainted with Express.js, you are probably already familiar with the middleware pattern, as Express relies heavily on it.

In the context of Vue, you can leverage this pattern through the Vue Router, which is the official routing library for Vue. The Vue Router provides a means to implement middleware-like functionality for route navigation, enabling you to incorporate custom behavior before or after navigating to specific routes.

Let’s dive into this pattern by demonstrating how to perform authentication checks before navigating to protected routes. We’ll utilize the Vue Router to set up navigation for different paths and complement it with straightforward middleware to execute authentication checks. To start, let’s create a Vue Router instance using the createRouter method. We’ll configure the router to use the browser’s createWebHistory API for history mode and define an array of route objects within the routes property.

Each route object contains a path property and specifies a corresponding Vue component to render when that route is accessed:

const router = createRouter({
  history: createWebHistory(),
  routes: [{
      path: '/',
      component: Home
    },
    {
      path: '/dashboard',
      component: Dashboard,
      meta: {
        requiresAuth: true
      }
    },
    {
      path: '/login',
      component: Login
    },
  ],
});

router.beforeEach((to, from, next) => {
  if (to.matched.some((record) => record.meta.requiresAuth)) {
    /*
     * Check if the user is authenticated, you can implement 
     * your authentication logic here.
     */
    if (!isAuthenticated) {
      // If not authenticated, redirect to the login page
      next({
        path: '/login',
        query: {
          redirect: to.fullPath
        },
      });
    } else {
      // If authenticated, proceed to the route
      next();
    }
  } else {
    // If the route does not require authentication, proceed
    next();
  }
});

export default router;
Enter fullscreen mode Exit fullscreen mode

One of the routes, /dashboard, has a meta property with the key requiresAuth set to true. This property indicates that accessing this particular route requires authentication.

We use the router.beforeEach function to define a global navigation guard, which is executed before every route navigation. Within this guard, we check whether the user is authenticated by using the authentication logic provided by our middleware:

  • If the route being accessed has a meta property with requireAuth set to true, the code checks whether the user is authenticated using isAuthenticated
    • If the user is authenticated, we call the next() method, allowing the route navigation to proceed as usual
    • If the user is not authenticated, we call the next('login') method, which redirects the user to the login route
  • If the route being accessed does not have the requireAuth meta property or if the user is authenticated, the code simply calls next(), enabling the route navigation to continue without any interruption

Our actual authentication logic resides in our middleware implementation, which may appear as follows:

// authMiddleware.js
const checkIfUserIsAuthenticated = () => {
  /*
   * Replace this with your actual authentication logic.
   * For demonstration purposes, assume the user is authenticated 
   * if a token exists in localStorage.
   */
  const token = localStorage.getItem('authToken');
  return !!token; // Convert to a boolean
};

const isAuthenticated = checkIfUserIsAuthenticated();

// A token in local storage is set here for demo purpose
const login = () => localStorage.setItem('authToken', 'your_auth_token');

// Clear the auth token from the storage
const logout = () => localStorage.removeItem('authToken');

export {
  isAuthenticated,
  login,
  logout
};
Enter fullscreen mode Exit fullscreen mode

In this demonstration, I’m allowing users to access protected pages by simply clicking on a login button, which then sets an authToken key to the browser’s local storage. This is for the sake of simplicity — in a real-world application, you would implement your actual authentication logic in this particular file.

Now, setting up the main component is relatively straightforward. However, it’s crucial to ensure that you register the router in the app’s entry point file, as shown below:

// main.js
const { createApp } = require('vue');
import App from './App';
import router from './router';

createApp(App)
  .use(router) // Register the router with the app
  .mount('#app');
Enter fullscreen mode Exit fullscreen mode

Below is how our implementation of the middleware pattern in a Vue app should look: Demo Of Middleware Design Pattern In Vue Showing A Simple Webpage With Three Bulleted Menu Items: Home, Dashboard, And Login. Home Page Is Labeled As Public Page. User Shown Clicking On Dashboard And Being Redirected To Login Page. Upon Clicking Login Button, User Is Redirected To Dashboard Page, Which Is Now Visible With Explanation That It Is A Protected Route You can also see the live demo on StackBlitz. Remember, the authentication logic here is simplified for demonstration purposes, and the primary focus is on understanding the middleware pattern.

Error handling pattern

Error handling design patterns and practices help developers manage and respond to errors effectively. These patterns are mostly behavioral in nature and aim to ensure that applications can gracefully handle unexpected situations, provide meaningful feedback to users, and facilitate debugging and testing.

In Vue, error handling primarily revolves around handling errors related to components, routing, network requests, and the application’s runtime. Let’s go over some common situations now.

try...catch blocks

If you have some code in your app that may potentially generate errors, it is best to encapsulate it within a try...catch block to promptly address any issues that may arise:

<script>
export default {
  methods: {
    fetchData() {
      try {
        // Code that may throw an error
        // ...
      } catch (error) {
        // Handle the error here
        console.error(`Error: ${error}`);
      }
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

This approach helps you handle errors quickly and is particularly useful for managing operations like API calls.

Error boundaries

Error boundary components are a powerful feature to help you handle and gracefully recover from errors that occur during the rendering of a component’s tree. They’re similar in concept to JavaScript’s try...catch blocks, but are specifically applied to Vue components.

To create an error boundary component in Vue, you need to define a component with a special lifecycle hook called errorCaptured . This hook is called whenever an error occurs in the component’s subtree or in any of its child components:

<template>
  <div>
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: 'ErrorBoundary',
  errorCaptured(err, vm, info) {
    // Handle the error
    console.log(err)
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

To use this error boundary component, simply wrap the component that you want to protect from errors in the error boundary component:

<template>
  <div>
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  </div>
</template>

<script>
import MyComponent from './MyComponent.vue'

export default {
  name: 'App',
  components: {
    ErrorBoundary,
    MyComponent
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

If an error occurs in the MyComponent component, the ErrorBoundary component’s errorCaptured hook will be called. You can then handle the error in the errorCaptured hook — for example, by logging the error to the console or displaying an appropriate error or warning message to the user.

Handling router errors

Using navigation guards with the Vue router can serve as an effective approach to prevent router errors in specific scenarios, such as user authentication, as we‘ve previously explored in the Middleware pattern.

Additionally, Vue Router provides its own error handling mechanism — the onError method, which allows you to establish a global error handler for effectively managing and handling errors that occur during navigation via Vue Router:

router.onError((err) => {
  console.error("Router error:", err);
});
Enter fullscreen mode Exit fullscreen mode

The err parameter, as shown above, contains information regarding the error that occurred during routing. This parameter typically provides insights into the reasons behind the routing failure, such as failed navigation or a missing route.

Within the error handler function, you have the flexibility to log the error using console.error or implement any other necessary error handling logic.

Global error handling

Setting up global error handling helps enhance error catching and makes error messages more intuitive and informative. You can achieve this by setting up global error handling at the entry point of your Vue application:

// main.js
import { createApp } from 'vue';

const app = createApp({});

// Global error handler
app.config.errorHandler = (error, instance, info) => {
  console.error("Global Error:", error);
  console.log("Vue Instance:", instance);
  console.log("Error Info:", info);
};

// Mount the Vue app to the HTML element with the id "app"
app.mount("#app");
Enter fullscreen mode Exit fullscreen mode

This approach allows for better error presentation, logging the Vue instance where the error occurred, and providing additional information about the error.

Conclusion

Design patterns are invaluable tools for creating well-structured, maintainable, and scalable applications. In this tutorial, as we explored various design patterns within the context of Vue, we encountered solutions tailored to specific challenges and requirements in Vue development.

It’s important to note that design patterns, like many aspects of software development, can be the subject of debate and differing opinions. Some developers may have strong preferences for certain patterns over others, leading to discussions about their appropriate usage in specific scenarios.

But generally speaking, understanding and applying design patterns in your Vue projects can help you write cleaner, more modular code that is easier to maintain and scale. Whether you’re a beginner or an experienced Vue developer, mastering these patterns will significantly improve your development skills.


Experience your Vue apps exactly how a user does

Debugging Vue.js applications can be difficult, especially when there are dozens, if not hundreds of mutations during a user session. If you’re interested in monitoring and tracking Vue mutations for all of your users in production, try LogRocket.

LogRocket Signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens in your Vue apps including network requests, JavaScript errors, performance problems, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.

The LogRocket Vuex plugin logs Vuex mutations to the LogRocket console, giving you context around what led to an error, and what state the application was in when an issue occurred.

Modernize how you debug your Vue apps - Start monitoring for free.

Top comments (1)

Collapse
 
leekinm profile image
leekinm

Great! 👏🏻 thank you for your introducing these design patterns which are much inspiration for me to optimizing my Vue applications.