Skip to main content

Vue.js Anti-patterns

Introduction

When learning Vue.js, you often focus on what you should do. However, understanding what you should avoid is equally important. Anti-patterns are common solutions to recurring problems that seem effective initially but have more negative consequences than positive ones in the long run.

In this guide, we'll explore common Vue.js anti-patterns that can lead to performance issues, maintenance nightmares, and bugs that are difficult to track down. By recognizing these patterns, you'll be able to write more robust, maintainable, and efficient Vue.js applications.

What are Anti-patterns?

Anti-patterns in Vue.js are practices that:

  • Negatively impact performance
  • Make code harder to maintain
  • Lead to unexpected bugs
  • Make reasoning about your application more difficult
  • Violate Vue's design principles

Let's explore the most common anti-patterns you should avoid.

Common Vue.js Anti-patterns

1. Mutating Props Directly

One of the most common anti-patterns in Vue is directly mutating props passed to a component.

❌ Bad Practice:

html
<template>
<div>{{ propValue }}</div>
</template>

<script>
export default {
props: ['propValue'],
mounted() {
// Directly modifying a prop - NEVER do this!
this.propValue = 'New Value';
}
}
</script>

✅ Better Approach:

html
<template>
<div>{{ localValue }}</div>
</template>

<script>
export default {
props: ['propValue'],
data() {
return {
localValue: this.propValue
}
},
watch: {
propValue(newVal) {
this.localValue = newVal;
}
},
mounted() {
// Modify the local copy, not the prop
this.localValue = 'New Value';
}
}
</script>

Why It Matters:

Props in Vue are meant to implement a one-way data flow. When you modify props directly:

  1. It violates the one-way data flow principle
  2. It makes tracking data changes difficult
  3. It can cause unexpected behavior in parent components
  4. Vue will warn you in the console when this happens

2. Using v-if with v-for on the Same Element

Using v-if and v-for on the same element causes performance issues because the v-for loop will run even when the v-if condition is false.

❌ Bad Practice:

html
<template>
<ul>
<li v-for="user in users" v-if="user.isActive" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>

✅ Better Approach:

html
<template>
<ul>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</ul>
</template>

<script>
export default {
computed: {
activeUsers() {
return this.users.filter(user => user.isActive);
}
}
}
</script>

Why It Matters:

  1. v-for has higher priority than v-if, so the loop will run for all items before filtering
  2. This is inefficient as it performs unnecessary iterations
  3. Using a computed property filters the data first, then only loops over what's necessary

3. Not Using Keys with v-for

Forgetting to use keys with v-for directives or using non-unique keys can lead to rendering issues and poor performance.

❌ Bad Practice:

html
<template>
<ul>
<li v-for="(item, index) in items">
{{ item.name }}
</li>
</ul>
</template>

Or even worse:

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

✅ Better Approach:

html
<template>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</ul>
</template>

Why It Matters:

  1. Keys help Vue identify which items have changed, been added, or been removed
  2. Without keys, Vue uses an algorithm that minimizes element movement but can cause issues with stateful components
  3. Using array indices as keys is problematic when the array order changes or items are added/removed
  4. Unique, stable IDs are the best choice for keys

4. Overusing Vuex for Simple State

Using Vuex for every piece of state, even in small applications or for component-specific state, adds unnecessary complexity.

❌ Bad Practice:

js
// store.js
export default new Vuex.Store({
state: {
// Global app state
currentUser: null,
// Component-specific state that doesn't need to be shared
sidebarOpen: false,
currentTablePage: 1,
formInputValue: ''
},
// ...mutations and actions
})

✅ Better Approach:

html
<!-- Component.vue -->
<script>
export default {
data() {
return {
// Local component state
sidebarOpen: false,
currentPage: 1,
inputValue: ''
}
},
computed: {
// Only map what truly needs to be global
...mapState(['currentUser'])
}
}
</script>

Why It Matters:

  1. Vuex adds complexity and boilerplate code
  2. It's designed for shared state across multiple components
  3. Component-specific state is better kept within the component
  4. For simple parent-child communication, props and events are often sufficient
  5. For sharing state between siblings, consider using provide/inject for simpler cases

5. Deep Component Nesting Without Proper Structure

Creating deeply nested component hierarchies without considering the data flow can make your app difficult to debug and maintain.

❌ Bad Practice:

AppRoot
└── Dashboard
└── UserSection
└── UserProfile
└── ProfileHeader
└── UserAvatar
└── AvatarBadge
// Needs data from Dashboard

When data needed at the bottom level must be passed through all intermediate components.

✅ Better Approach:

Consider one of these alternatives:

  1. Use Vuex for truly shared state
  2. Use Vue's provide/inject API for specific dependency injection
html
<!-- Dashboard.vue -->
<script>
export default {
provide() {
return {
userInfo: this.userInfo
}
},
data() {
return {
userInfo: { /* ... */ }
}
}
}
</script>

<!-- Deep child component -->
<script>
export default {
inject: ['userInfo']
}
</script>

Why It Matters:

  1. Prop drilling (passing props through many layers) makes code harder to maintain
  2. Changes to intermediate components can break the data flow
  3. It's difficult to track where data is coming from

6. Using Methods for Computed Values

Using methods instead of computed properties for derived values that should be cached based on their dependencies.

❌ Bad Practice:

html
<template>
<div>
<p>{{ getFullName() }}</p>
<p>{{ getFullName() }}</p>
<p>{{ getFullName() }}</p>
</div>
</template>

<script>
export default {
data() {
return {
firstName: 'John',
lastName: 'Doe'
}
},
methods: {
getFullName() {
console.log('Method called!'); // Will log 3 times
return this.firstName + ' ' + this.lastName;
}
}
}
</script>

✅ Better Approach:

html
<template>
<div>
<p>{{ fullName }}</p>
<p>{{ fullName }}</p>
<p>{{ fullName }}</p>
</div>
</template>

<script>
export default {
data() {
return {
firstName: 'John',
lastName: 'Doe'
}
},
computed: {
fullName() {
console.log('Computed property called!'); // Will log only once
return this.firstName + ' ' + this.lastName;
}
}
}
</script>

Why It Matters:

  1. Computed properties are cached based on their dependencies
  2. Methods are re-evaluated every time they're called
  3. For derived values that depend on other data, computed properties are more efficient
  4. This can have significant performance implications in larger applications

7. Creating Too Many Event Listeners

Adding too many global event listeners without properly removing them can lead to memory leaks.

❌ Bad Practice:

html
<script>
export default {
mounted() {
window.addEventListener('resize', this.handleResize);
document.addEventListener('click', this.handleClick);
window.addEventListener('scroll', this.handleScroll);
}
// Event listeners are never removed!
}
</script>

✅ Better Approach:

html
<script>
export default {
mounted() {
window.addEventListener('resize', this.handleResize);
document.addEventListener('click', this.handleClick);
window.addEventListener('scroll', this.handleScroll);
},
beforeUnmount() {
// Vue 3 syntax
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('click', this.handleClick);
window.removeEventListener('scroll', this.handleScroll);
},
// Use beforeDestroy in Vue 2
}
</script>

Why It Matters:

  1. Event listeners persist even after a component is destroyed
  2. This can lead to memory leaks as the callback function maintains references to the component
  3. Events might be fired for components that no longer exist, causing errors
  4. Always clean up your event listeners in the beforeUnmount (Vue 3) or beforeDestroy (Vue 2) lifecycle hook

8. Using Object or Array Literals in Templates

Using object or array literals directly in template renders can cause unnecessary re-renders.

❌ Bad Practice:

html
<template>
<component-a :config="{ option: 'value' }"></component-a>
<component-b :items="[1, 2, 3]"></component-b>
</template>

✅ Better Approach:

html
<template>
<component-a :config="config"></component-a>
<component-b :items="items"></component-b>
</template>

<script>
export default {
data() {
return {
config: { option: 'value' },
items: [1, 2, 3]
}
}
}
</script>

Why It Matters:

  1. When inline objects or arrays are used in templates, they are re-created on every render
  2. This can trigger unnecessary updates in child components
  3. Defining these values in data or computed properties ensures referential stability

Real-World Example: Refactoring an Anti-pattern App

Let's look at a small app with several anti-patterns and refactor it.

Before: App with Multiple Anti-patterns

html
<template>
<div>
<h1>User Dashboard</h1>

<!-- Anti-pattern: v-if with v-for -->
<div v-for="(user, index) in users" v-if="user.isActive" :key="index">
{{ user.name }}

<!-- Anti-pattern: Inline object literal -->
<user-card :user-data="{ id: user.id, name: user.name }"></user-card>

<!-- Anti-pattern: Method used for computed value -->
<span>Score: {{ calculateScore(user) }}</span>

<button @click="user.isActive = false">Deactivate</button> <!-- Anti-pattern: Mutating prop -->
</div>
</div>
</template>

<script>
export default {
props: ['users'],
methods: {
calculateScore(user) {
// Complex calculation that happens on every render
return user.points * user.multiplier;
}
},
mounted() {
window.addEventListener('resize', this.checkScreenSize);
// No cleanup for event listener
}
}
</script>

After: Refactored App Following Best Practices

html
<template>
<div>
<h1>User Dashboard</h1>

<!-- Fixed: Using computed property for filtering -->
<div v-for="user in activeUsers" :key="user.id">
{{ user.name }}

<!-- Fixed: Using precomputed data -->
<user-card :user-data="getUserCardData(user)"></user-card>

<!-- Fixed: Using computed property -->
<span>Score: {{ getUserScore(user.id) }}</span>

<!-- Fixed: Emitting event instead of mutating prop -->
<button @click="$emit('deactivate-user', user.id)">Deactivate</button>
</div>
</div>
</template>

<script>
export default {
props: {
users: {
type: Array,
required: true
}
},
emits: ['deactivate-user'],
computed: {
activeUsers() {
return this.users.filter(user => user.isActive);
},
userScores() {
// Calculate all scores once
const scores = {};
this.users.forEach(user => {
scores[user.id] = user.points * user.multiplier;
});
return scores;
}
},
methods: {
getUserCardData(user) {
return { id: user.id, name: user.name };
},
getUserScore(userId) {
return this.userScores[userId];
},
checkScreenSize() {
// Handler for resize events
}
},
mounted() {
window.addEventListener('resize', this.checkScreenSize);
},
beforeUnmount() {
window.removeEventListener('resize', this.checkScreenSize);
}
}
</script>

Improvements Made:

  1. Replaced v-for with v-if by using a computed property activeUsers
  2. Added proper key using unique ID instead of index
  3. Used computed property userScores to cache calculation results
  4. Added prop validation
  5. Used an emit event instead of mutating the prop directly
  6. Added cleanup for the event listener in beforeUnmount
  7. Improved method organization and focused responsibilities

Additional Anti-patterns to Watch For

Here are a few more anti-patterns to be aware of:

  1. Excessive Mixins: Relying too heavily on mixins can lead to "mixin hell" where it's unclear where methods and properties come from. Consider using composition API (Vue 3) or more focused components.

  2. Large Single-File Components: When components grow too large, they become difficult to maintain. Break them down into smaller, focused components.

  3. Not Using Functional Components: For simple presentational components that don't need state or lifecycle hooks, using functional components can improve performance.

  4. Manually Managing DOM: Directly manipulating the DOM with this.$el or querySelector instead of using Vue's reactive system.

  5. Non-idiomatic Component Names: Using non-descriptive or single-word component names can lead to conflicts with existing and future HTML elements.

Summary

Understanding and avoiding Vue.js anti-patterns is crucial for building maintainable and performant applications. In this guide, we've covered several common mistakes:

  • Mutating props directly
  • Using v-if with v-for on the same element
  • Not using keys with v-for or using non-unique keys
  • Overusing Vuex for simple state
  • Creating deeply nested component hierarchies
  • Using methods instead of computed properties
  • Not cleaning up event listeners
  • Using object or array literals in templates

By avoiding these anti-patterns, you'll write more efficient Vue applications that are easier to maintain, debug, and extend.

Additional Resources

For deeper understanding of Vue.js best practices:

Exercises

  1. Refactoring Practice: Take an existing Vue component from your project and identify potential anti-patterns. Refactor it following the best practices outlined in this guide.

  2. Performance Comparison: Create two versions of a component with a list of 1000 items - one using the anti-pattern of v-if with v-for, and another using the correct approach with a computed property. Compare their rendering performance.

  3. Code Review: Review a peer's Vue.js code and identify any anti-patterns. Provide constructive feedback on how they could be improved.

  4. Memory Leak Detection: Create a component with event listeners that aren't properly cleaned up. Use browser developer tools to verify that it causes a memory leak, then fix it and confirm the issue is resolved.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)