MEDIA GUIDES / Web Performance

Vue Best Practices: A Practical Guide to Clean, Fast, and Scalable Apps

If you use the internet often, you’ve definitely run into web pages that load extremely slow or have terrible, shoddy user experience. Oftentimes, the main reason these websites are slow isn’t usually the programming tools they use, but often comes down to not following the basic rules (fundamentals) of building for the web or for that specific programming framework.

Vue.js is a popular framework for building web apps. It helps you create user interfaces that are interactive and easy to manage. However, to make your Vue apps clean, fast, and able to grow big without problems, you need to follow some best practices. In this guide, we’ll cover essential best practices to help you write clean, fast, and scalable Vue applications that are easy to work with, both for you and your team.

In this article:

Project Structure and Component Naming Conventions

A well-organized project is the foundation of any maintainable application. Many developers often overlook the importance and impact of a clean and intuitive codebase, which always ultimately impacts developer experience and code maintenance. Although Vue has an official style-guide for writing Vue-specific code, it doesn’t enforce a specific project structure.

If you bootstrap your Vue project using the recommended create-vue tool, it generates a consistent and predictable project structure by default, making it easier to navigate and understand the codebase, especially when working in teams or on larger projects. A typical Vue app has the following folder setup:

  • src/: This is the main folder for your code. It contains the following subfolders:
    • assets/: For images, fonts, and other static files.
    • components/: For storing reusable UI pieces, like buttons or forms.
    • views/: For full-page components, like HomeView or AboutView.
    • router/: Files for handling page navigation.
    • store/: If using state management, like Pinia.
    • styles/: Global CSS files.
    • utils/: Helper functions that you use in many places.
  • public/: For files that should be served directly without processing, such as static HTML, favicons, and metadata.

Component Names

Components should be named clearly using PascalCase; meaning starting with a capital letter and capitalizing each new word. Avoid naming components like dashboardSidebar, btn.vue or Items_List.

You can also group similar components together in subfolders for easy access and data/state sharing. For instance, you can have a components/forms/ subfolder containing the following components that are used together:

  • InputField.vue
  • SubmitButton.vue
  • FileUpload.vue
  • UploadProgress.vue

Use The Reactivity API and Pinia For State Management

Every interactive Vue app has a state, AKA the source of truth that drives data flow. Similarly, every Vue component can manage its own local state using refs.

<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

<template>
  <div>
    <div>{{ count }}</div>
    <button @click="increment">Increment</button>
  </div>
</template>

In the above example, ref() is a function from Vue’s Composition API used to create a reactive and mutable reference to a single value, typically for primitive data types (strings, numbers, booleans) or individual objects.

For state or data that needs to be shared across many instances and components (a.k.a., global state), the Reactivity API allows us to create a reactive object which can be imported into multiple components.

Step 1: Create the Shared Data (the store):

Use reactive() in a separate file (e.g., store.js) to make the data object reactive.

Example:

// store.js
import { reactive } from 'vue'
export const store = reactive({
  count: 0
})

Step 2: Use the Shared Data:

Any component can now import and display the data:

// MyComponent.vue
<script setup>
import { store } from './store.js'
</script>

<template>From A: {{ store.count }}</template>

If one component changes store.count, all the other components will update automatically.

To prevent any component from changing the data haphazardly, it’s best to define methods (actions) directly on the store object itself. Here’s an example that adds an increment() method to the store:

// store.js 
import { reactive } from 'vue'
export const store = reactive({
  count: 0,
  increment() { // <-- The centralized method
    this.count++
  }
})

Components can then call the method:

// MyOtherComponent.vue
<template>
  <button @click="store.increment()">
    From another component: {{ store.count }}
  </button>
</template>

Pinia

Pinia is Vue.js official state management library with more sophisticated capabilities for handling state in large and complex Vue apps. It’s simple, integrates well with the Composition API and Vue Devtools, has Hot Module Replacement, plugins, and several other cool features that make it joyful to use.

Here’s an example that uses Pinia to manage a user auth status app-wide:

// stores/userStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref(null)
  const isAuthenticated = ref(false)


  // Getters (computed)
  const fullName = computed(() => {
    return user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
  })


  // Actions
  async function login(credentials) {
    const response = await api.login(credentials)
    user.value = response.user
    isAuthenticated.value = true
    localStorage.setItem('token', response.token)
  }


  function logout() {
    user.value = null
    isAuthenticated.value = false
    localStorage.removeItem('token')
  }


  return {
    user,
    isAuthenticated,
    fullName,
    login,
    logout
  }
})

Overall, when dealing with state in a Vue app, start with local state using the Reactivity API, then gradually transition to Pinia as your app grows and becomes more complex.

Props, Emits, and Type-Safe Interfaces with defineProps/defineEmits

In Vue, props let parent components send data to child components, while emits let children send events back to parents. In Vue 3, use defineProps and defineEmits for better type safety, especially with TypeScript.

In a child component, you can define props like this:

<script setup lang="ts">
const props = defineProps<{
  name: string;
  age: number;
}>();
</script>

This says the prop “name” must be a string, and “age” a number. If you pass wrong types, your editor warns you.

For emits:

<script setup lang="ts">
const emit = defineEmits<{
  (e: 'update', payload: string): void;
}>();

function handleClick() {
  // TypeScript will now ensure that a string is passed when calling emit('update')
  emit('update', 'new value');
}
</script>

Maintain Good Template Hygiene

Vue uses an HTML-based template syntax to bind a component’s data with the content rendered in the DOM. Clean templates are easier to read and maintain, and Vue provides several directives that, when used correctly, make your templates more expressive. Here are some general practices to follow:

  • Always use :key with v-for when rendering a list using a unique :key attribute (usually the item’s ID). This helps Vue efficiently track changes, reuse, and reorder elements, improving performance.
<!-- Good practice: Use unique ID -->
<ul>
  <li v-for="user in users" :key="user.id">
    {{ user.name }}
  </li>
</ul>

<!-- Bad Practice: Using index as key unless list is static -->
<ul>
  <li v-for="(item, index) in items" :key="index">
    {{ item.name }}
  </li>
</ul>
  • For conditional rendering, prefer v-if for one-time checks and v-show for toggling visibility (it keeps the element in DOM).
  • Use shorthands where possible to save typing and keep your code simple. For instance, use:
    • :prop instead of v-bind:prop
    • @event instead of v-on:event
  • When using functions within template binding expressions (for example., {{ myFunction() }} or :attribute="myFunction()"), they must be pure and not mutate component data, trigger API calls, or perform any asynchronous operations.
  • Use the v-html directive only for trusted content to prevent XSS attacks.

Optimize for Performance Where Possible

As developers, one of the things we strive for when developing apps is to make them as fast as possible while delivering a great user experience. Performance optimization is a tricky concept, but luckily, Vue is designed to be performant by default. Additionally, Vue provides useful tools and techniques that can help optimize your app’s performance with minimal effort for different scenarios. Here are some strategies you can adopt to improve the performance of your vue apps.

Use Computed Properties and Watchers

According to Vue documentation, a computed property will only trigger effects when its computed value has changed from the previous one. Use computed for values that depend on other reactive data since they are cached and only re-run when their dependencies change.

For example, imagine you have a list of products and want to display only those that match a search query. A computed property can automatically recalculate the filtered list only when the products array or the search query changes, instead of recalculating on every render:

<script setup>
import { ref, computed } from "vue";

const search = ref("");
const products = ref([
  { name: "Laptop" },
  { name: "Keyboard" },
  { name: "Shoes" },
  { name: "Smartphone" }
]);

const filteredProducts = computed(() => {
  return products.value.filter(product =>
    product.name.toLowerCase().includes(search.value.toLowerCase())
  );
});
</script>

<template>
  <input v-model="search" placeholder="Search products..." />

  <ul>
    <li v-for="(product, index) in filteredProducts" :key="index">
      {{ product.name }}
    </li>
  </ul>
</template>

Watchers (watch()) in Vue.js are a mechanism for observing changes in reactive data properties and executing a callback function or “side effects” in response to those changes. In the following example, the watch function observes the message ref. Whenever message.value changes, the provided callback function will log the old and new values to the console.

import { ref, watch } from 'vue';

export default {
  setup() {
    const message = ref('Hello');

    watch(message, (newValue, oldValue) => {
      console.log(`Message changed from "${oldValue}" to "${newValue}"`);
      // Perform side effect, e.g., update a database or make an API call
    });

    return {
      message
    };
  }
};

Leverage Performance Optimization Directives

v-once

The v-once directive tells Vue to render the element and its content only a single time and skip all future updates. This is useful when displaying content that is completely static, as it prevents unnecessary re-rendering and can significantly improve performance, especially in large or frequently updated components.

In the following example, the first paragraph updates whenever the message changes, while the second paragraph using v-once keeps the original value and never updates:

<div id="app">
  <p>Standard render: {{ message }}</p>

  <p v-once>v-once render: {{ message }}</p>

  <button @click="updateMessage">Update Message</button>
</div>

<script type="module">
import { createApp, ref } from "vue";

const App = {
  setup() {
    const message = ref("Hello Vue!");

    const updateMessage = () => {
      message.value = "Message Updated!";
    };

    return {
      message,
      updateMessage
    };
  }
};

createApp(App).mount("#app");
</script>

v-memo

The v-memo directive is used to conditionally skip updates to an element and its children based on a list of dependency values. Here’s how it works:

  1. Initial Render: Vue renders the element and stores the initial values of the expressions passed to v-memo.
  2. Subsequent Updates: Before re-rendering the component, Vue checks if the values in the v-memo dependency array have changed.
        • If all values are the same as the last render, the entire element subtree is skipped.
        • If any value has changed, the element and its children are re-rendered normally.

For example, imagine we need to render a list of 1000+ items. Each item only updates if its selected state changes, meaning Vue can reuse the existing VNode for items that weren’t affected and avoid comparing their children. We can optimize the rendering with v-memo as follows:

<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
  <p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
  <p>...more child nodes</p>
</div>

Use Lazy Loading and Code Splitting

In web development, lazy loading is a performance optimization technique that delays the loading of non-critical resources, like images and videos, until they are needed. In Vue, we can lazy load large components that are not immediately visible when users first open our app or website, preventing unnecessary network requests and bandwidth usage.

<script setup>
import { defineAsyncComponent } from 'vue';

const HeavyModal = defineAsyncComponent(() => import('./HeavyModal.vue'));
</script>

In the above example, the HeavyModal component is imported dynamically using Vue’s defineAsyncComponent, which makes it asynchronous, hence the name “dynamic imports”.

The import() function differentiates a dynamic import from the normal static import we’re used to:

// Here, the HeavyModal component is imported statically
<script setup>
import HeavyModal from './HeavyModal.vue'
</script>

Code splitting allows us to break down the main JavaScript bundle into smaller, more manageable chunks during the production build process. These chunks are then loaded on demand, rather than all at once during the initial page load, making the app load faster.

This is usually configured and enabled by the underlying bundler in your application. Vite and other bundlers do this by detecting the ESM dynamic import syntax in your code. For route-level code splitting, Vue Router supports dynamic imports by default:

// router/index.ts

const routes = [
  {
    path: '/',
    name: 'home',
    component: () => import('./views/AboutView.vue') // The About page is lazy loaded
  }
];

Use Scoped CSS, CSS Modules, and BEM For Styling

There are numerous methods to style a Vue app. The official style guide recommends using component-scoped styling, that is, keeping styles local to each component. This helps to keep your styles organized and isolated to prevent conflicts.

  • Scoped CSS (Recommended Default): This method follows the Single-File Component (SFC) architecture. It involves using the <style scoped> tag to instruct Vue to apply the CSS to elements of the current component only.
// MyComponent.vue
<template>
  <div class="example">hi</div>
</template>

<style scoped>
.example {
  color: red;
}
</style>
  • CSS Modules: CSS modules allow us to create scoped styling for a component, but with additional benefits. You can enable CSS Modules by adding the module attribute to the <style> tag in a single-file component, or by naming your stylesheet with a .module.css extension and importing it directly into your component.
  • Block-Element-Modifier (BEM) Naming Convention: The BEM naming convention is a structured way of writing CSS class names to improve clarity and maintainability. It breaks styles into three parts:
  • Block: the main standalone component (.user-card)
  • Element: a child part of the block that performs a function (.user-card__avatar)
  • Modifier: a variation or state of the block or element (.user-card--dark)

Wrapping Up

Building an app is one thing, while making it conform to industry best practices is another. Adopting these best practices is a process that requires a lot of effort and commitment. This commitment ultimately leads to a superior user experience, faster iteration, and a more robust application that can easily scale and adapt to future demands.

Frequently Asked Questions

How do I decide between putting a component in /components vs /views?

Though both folders are used for storing Vue components, they serve different purposes in your application structure. Use the following guideline:

  • /components: For reusable, presentational components used in multiple places
  • /views (or /pages): For route-level components that represent entire pages

For example, a Button.vue component goes in /components, while Dashboard.vue (mapped to /dashboard route) goes in /views.

When should I use Pinia instead of just the Reactivity API?

Use Pinia when you need to share across multiple components that aren’t in a parent-child relationship, have a complex state with multiple actions and getters, or need the devtools for debugging and state inspection.

Can I use Vue with other frameworks/libraries?

Yes. Vue is designed to be highly adaptable and integrates well with several backend frameworks, UI libraries, and so on.

QUICK TIPS
Tamas Piros
Cloudinary Logo Tamas Piros

In my experience, here are tips that can help you better build clean, fast, and scalable Vue apps:

  1. Modularize utility logic into composables
    Use the composables/ directory to extract logic reused across components (e.g., useAuth, usePagination). This improves testability, reduces duplication, and aligns with the Composition API philosophy.
  2. Track reactive dependency scope with effectScope
    For advanced memory management, use effectScope() to create isolated reactive scopes. This is crucial in large apps where you want to clean up unused state (e.g., modals or temporary widgets).
  3. Profile component performance with devtools flamegraph
    The Vue Devtools “Performance” tab offers a flamegraph for identifying bottlenecks. Use this to track which components re-render most often and optimize them with v-once, memoization, or restructuring.
  4. Build atomic design systems with nested slots
    Go beyond simple slot usage: use nested slots to build flexible, design-system-ready components (e.g., card components with slots for header, body, footer). This ensures scalability without bloating props or logic.
  5. Use async setup for cleaner server-side logic
    Vue 3 supports async setup(), ideal for SSR or data-fetching apps. Combine with Suspense to manage async data and loading states declaratively, minimizing conditional logic in templates.
  6. Structure stores around features, not types
    Instead of having one store per data type (user, product, etc.), group Pinia stores by feature (e.g., useCheckoutStore, useChatStore). This aligns better with the mental model of real app workflows.
  7. Encapsulate animation logic with Motion One or VueUse/motion
    Instead of raw CSS or imperative JS animations, use libraries like Motion One integrated via Vue directives to manage animations declaratively and smoothly. This keeps animations responsive and easier to maintain.
  8. Cache expensive computed results with custom getters
    Use shallow reactive caching or memoized computed getters when derived data is complex (e.g., filtering + sorting large arrays). You can write custom caching logic that bypasses recalculations unless inputs truly change.
  9. Control render timing with flush: post watchers
    When watching reactive data that affects layout or DOM size (e.g., animations), use { flush: 'post' } to defer updates until after DOM painting. This improves UX by reducing flicker or layout shifts.
  10. Audit bundle size with rollup-plugin-visualizer in Vite
    Use the visualizer plugin to analyze your final JS/CSS chunks. It helps you spot large dependencies or duplicated modules early, giving insight into where lazy loading or tree shaking could improve load time.
Last updated: Dec 19, 2025