Skip to content
All essays
WebMarch 27, 202516 min

Vue.js Best Practices and Patterns

Learn Vue.js best practices for building scalable applications. Component design, state management, performance optimization, and more.

Ü
Ümit Uz
Mobile & Full Stack Developer

Following best practices in Vue.js development ensures maintainable, scalable, and performant applications.

Component Design

Single Responsibility Principle

Each component should have a single, well-defined purpose:

vue
<!-- ❌ Bad: Too many responsibilities -->
<template>
  <div>
    <UserForm />
    <UserList />
    <UserStats />
    <UserNotifications />
  </div>
</template>

<!-- ✅ Good: Focused component -->
<template>
  <div>
    <UserList />
  </div>
</template>

Component Composition

Compose smaller components into larger ones:

vue
<!-- components/BaseButton.vue -->
<template>
  <button
    :class="[variant, size]"
    :disabled="disabled"
    @click="$emit('click')"
  >
    <slot />
  </button>
</template>

<script setup>
defineProps({
  variant: { type: String, default: 'primary' },
  size: { type: String, default: 'md' },
  disabled: { type: Boolean, default: false }
})

defineEmits(['click'])
</script>

<!-- Usage -->
<BaseButton variant="secondary" size="lg" @click="handleClick">
  Click me
</BaseButton>

Props and Emits

Define Props Explicitly

vue
<script setup>
// ✅ Good: Explicit prop definitions
const props = defineProps({
  title: {
    type: String,
    required: true
  },
  count: {
    type: Number,
    default: 0
  },
  user: {
    type: Object,
    default: () => ({ name: 'Guest' })
  }
})

// Define emits
const emit = defineEmits(['update', 'delete'])
</script>

Use v-model for Two-Way Binding

vue
<!-- Parent -->
<template>
  <ChildComponent v-model:title="pageTitle" />
</template>

<!-- Child -->
<template>
  <input
    :value="title"
    @input="$emit('update:title', $event.target.value)"
  />
</template>

<script setup>
defineProps(['title'])
defineEmits(['update:title'])
</script>

Composables

Extract Reusable Logic

javascript
// composables/usePagination.js
import { ref, computed } from 'vue'

export function usePagination(items, itemsPerPage = 10) {
  const currentPage = ref(1)

  const totalPages = computed(() =>
    Math.ceil(items.value.length / itemsPerPage)
  )

  const paginatedItems = computed(() => {
    const start = (currentPage.value - 1) * itemsPerPage
    const end = start + itemsPerPage
    return items.value.slice(start, end)
  })

  function nextPage() {
    if (currentPage.value < totalPages.value) {
      currentPage.value++
    }
  }

  function prevPage() {
    if (currentPage.value > 1) {
      currentPage.value--
    }
  }

  function goToPage(page) {
    currentPage.value = Math.max(1, Math.min(page, totalPages.value))
  }

  return {
    currentPage,
    totalPages,
    paginatedItems,
    nextPage,
    prevPage,
    goToPage
  }
}

Compose Composables

javascript
// composables/useAsyncOperation.js
export function useAsyncOperation() {
  const loading = ref(false)
  const error = ref(null)

  async function execute(asyncFn) {
    loading.value = true
    error.value = null

    try {
      return await asyncFn()
    } catch (err) {
      error.value = err
      throw err
    } finally {
      loading.value = false
    }
  }

  return { loading, error, execute }
}

// Usage
const { loading, error, execute } = useAsyncOperation()
const { data } = await execute(() => fetch('/api/users'))

Performance Optimization

Lazy Loading Components

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

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

<template>
  <HeavyComponent />
</template>

v-once for Static Content

vue
<template>
  <div v-once>
    <h1>{{ staticTitle }}</h1>
    <p>{{ staticContent }}</p>
  </div>
</template>

Computed vs Methods

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

const items = ref([])

// ✅ Good: Use computed for derived state
const filteredItems = computed(() =>
  items.value.filter(item => item.active)
)

// ❌ Bad: Using method in template
function getFilteredItems() {
  return items.value.filter(item => item.active)
}
</script>

<template>
  <div v-for="item in filteredItems" :key="item.id">
    {{ item.name }}
  </div>
</template>

State Management

Local State vs Global State

javascript
// ✅ Good: Use local state for component-specific data
const localCount = ref(0)

// ✅ Good: Use Pinia for shared state
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()

Avoid Props Drilling

vue
<!-- ❌ Bad: Props drilling -->
<template>
  <Parent :user="user" />
</template>

<!-- ✅ Good: Use provide/inject or Pinia -->
<script setup>
import { provide } from 'vue'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
provide('user', userStore.user)
</script>

Naming Conventions

Component Naming

javascript
// ✅ Good: Multi-word, descriptive names
components/UserProfile.vue
components/BaseButton.vue
components/SearchInput.vue

// ❌ Bad: Single-word or generic names
components/User.vue
components/Button.vue
components/Input.vue

Event Naming

vue
<script setup>
// ✅ Good: Descriptive event names
const emit = defineEmits(['user-updated', 'form-submitted', 'item-deleted'])

// ❌ Bad: Generic event names
const emit = defineEmits(['update', 'submit', 'delete'])
</script>

Error Handling

Global Error Handler

javascript
// main.js
app.config.errorHandler = (err, instance, info) => {
  console.error('Global error:', err)
  console.error('Component:', instance)
  console.error('Info:', info)

  // Send to error tracking service
  trackError(err, { info })
}

Error Boundaries

vue
<template>
  <div v-if="error">
    <h2>Something went wrong</h2>
    <button @click="reset">Try again</button>
  </div>
  <slot v-else />
</template>

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

const error = ref(null)

onErrorCaptured((err) => {
  error.value = err
  return false // Prevent error propagation
})

function reset() {
  error.value = null
}
</script>

TypeScript Integration

Typing Props

vue
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

interface Props {
  user: User
  title: string
  count?: number
}

const props = withDefaults(defineProps<Props>(), {
  count: 0
})
</script>

Typing Emits

vue
<script setup lang="ts">
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}

const emit = defineEmits<Emits>()
</script>

Testing

Component Testing

javascript
// tests/components/Button.spec.js
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'

describe('Button', () => {
  it('emits click event', async () => {
    const wrapper = mount(Button)
    await wrapper.trigger('click')

    expect(wrapper.emitted('click')).toBeTruthy()
  })

  it('disables button when disabled prop is true', () => {
    const wrapper = mount(Button, {
      props: { disabled: true }
    })

    expect(wrapper.find('button').attributes('disabled')).toBeDefined()
  })
})

Code Organization

Feature-Based Structure

src/
├── features/
│   ├── auth/
│   │   ├── components/
│   │   ├── composables/
│   │   ├── stores/
│   │   └── types/
│   └── users/
│       ├── components/
│       ├── composables/
│       └── stores/
├── shared/
│   ├── components/
│   ├── composables/
│   └── utils/
└── router/

Following these best practices will help you build maintainable, scalable, and performant Vue.js applications.

Related essays

Next essay
SvelteKit: The Complete Framework