Vue.js occupies a unique position in the front-end landscape. It offers the progressive adoption model that React lacks and the performance that Angular struggles to match. Vue 3, powered by the Composition API and a proxy-based reactivity system, is a mature framework for building everything from lightweight marketing sites to complex single-page applications.
At Pepla, we use Vue for client portals, internal dashboards, and marketing platforms where fast development velocity and a gentle learning curve matter. This article covers the patterns and practices we have refined across production Vue 3 projects.
Composition API: Why It Replaced Options API
The Options API organised code by option type: data in one block, methods in another, computed properties in a third, lifecycle hooks scattered throughout. This worked for small components but fell apart in large ones. Related logic was fragmented across the file, and sharing logic between components required mixins, which introduced naming collisions and implicit dependencies.
The Composition API organises code by logical concern. All the code related to user search -- the query ref, the debounced watcher, the API call, the results -- lives together. And because it is plain JavaScript functions, logic extraction is trivial:
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useApi } from '@/composables/useApi'
// --- Search logic ---
const query = ref('')
const { data: results, loading, execute } = useApi<Product[]>('/api/products')
const debouncedSearch = useDebounceFn(() => {
if (query.value.length >= 2) {
execute({ params: { q: query.value } })
}
}, 300)
watch(query, debouncedSearch)
// --- Filtering logic ---
const categoryFilter = ref<string | null>(null)
const filteredResults = computed(() => {
if (!results.value) return []
if (!categoryFilter.value) return results.value
return results.value.filter(p => p.category === categoryFilter.value)
})
</script>
The <script setup> syntax is not just syntactic sugar. It compiles to a more efficient render function than the equivalent Options API code because the compiler can analyse the template bindings statically and generate optimised update paths.
The Composition API organises code by logical concern, not option type -- related logic stays together, making components easier to maintain.
Reactivity: Refs, Reactive, and Computed
Vue 3's reactivity system is built on JavaScript Proxies. Understanding the difference between ref and reactive is essential for avoiding subtle bugs.
A ref wraps a single value in a reactive container. You access the value via .value in JavaScript, but Vue automatically unwraps it in templates. Use ref for primitives and for any value you might reassign entirely:
const count = ref(0) count.value++ // reactive update const user = ref<User | null>(null) user.value = await fetchUser(id) // reassignment works
A reactive wraps an object and makes all its properties reactive. You cannot reassign the entire object, and destructuring breaks reactivity. Use reactive for complex objects you will mutate in place:
const form = reactive({
name: '',
email: '',
message: '',
})
// This works -- mutating a property
form.name = 'Johan'
// This breaks reactivity -- destructuring
const { name } = form // name is NOT reactive
The rule at Pepla is simple: use ref by default. Only reach for reactive when you have a form object or a complex state object that benefits from direct property access without .value.
computed creates a derived value that automatically recalculates when its dependencies change. Computed values are cached: if the dependencies have not changed, accessing a computed property returns the cached result without re-executing the getter:
const items = ref<CartItem[]>([])
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const formattedTotal = computed(() =>
new Intl.NumberFormat('en-ZA', {
style: 'currency',
currency: 'ZAR',
}).format(totalPrice.value)
)
Pinia: State Management Done Right
Pinia replaced Vuex as Vue's official state management library. It is lighter, fully typed, and built for the Composition API. Where Vuex required mutations, actions, and getters as separate concepts with boilerplate, Pinia collapses everything into a clean store definition:
// stores/useAuthStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
// Getters
const isAuthenticated = computed(() => !!token.value)
const displayName = computed(() => user.value?.name ?? 'Guest')
// Actions
async function login(email: string, password: string) {
const response = await api.post('/auth/login', { email, password })
token.value = response.data.token
user.value = response.data.user
localStorage.setItem('token', response.data.token)
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('token')
}
return { user, token, isAuthenticated, displayName, login, logout }
})
Consuming the store in a component is straightforward:
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
const auth = useAuthStore()
// auth.isAuthenticated, auth.login(), auth.logout()
</script>
<template>
<div v-if="auth.isAuthenticated">
Welcome, {{ auth.displayName }}
<button @click="auth.logout()">Logout</button>
</div>
</template>
Component Design: Props, Emits, and Slots
Well-designed components follow a contract: props flow down, events flow up, and slots provide composition points. TypeScript makes this contract explicit:
<script setup lang="ts">
interface Props {
title: string
variant?: 'primary' | 'secondary' | 'danger'
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
disabled: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
focus: []
}>()
</script>
<template>
<button
:class="['btn', `btn--${variant}`]"
:disabled="disabled"
@click="emit('click', $event)"
@focus="emit('focus')"
>
<slot name="icon" />
<span>{{ title }}</span>
<slot name="badge" />
</button>
</template>
Named slots give consumers control over specific parts of the component without breaking the component's structure. Default slots are for the primary content; named slots are for optional extensions. Scoped slots pass data back to the parent, enabling powerful renderless component patterns.
Props down, events up, slots for composition -- this contract keeps components predictable and reusable across your application.
Vue Router: Navigation and Guards
Vue Router handles client-side navigation with support for nested routes, dynamic segments, and navigation guards. Route-level code splitting is essential for performance in larger applications:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'projects',
component: () => import('@/views/ProjectsView.vue'),
},
{
path: 'projects/:id',
component: () => import('@/views/ProjectDetailView.vue'),
props: true,
},
],
},
],
})
// Global navigation guard
router.beforeEach((to, from) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
Every route component is lazy-loaded with dynamic imports, meaning the browser only downloads the JavaScript for the route the user is visiting. Vite handles the chunk splitting automatically.
Suspense and Async Components
Vue 3's Suspense component provides a declarative way to handle async operations in the component tree. When a component's setup function returns a promise (or uses top-level await in <script setup>), Suspense shows a fallback until the promise resolves:
<template>
<Suspense>
<template #default>
<ProjectDashboard />
</template>
<template #fallback>
<SkeletonLoader />
</template>
</Suspense>
</template>
Testing with Vitest
Vitest is the natural testing companion for Vue + Vite projects. It shares Vite's configuration, understands Vue SFCs natively, and runs tests significantly faster than Jest because it reuses Vite's module transformation pipeline:
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ProductCard from '@/components/ProductCard.vue'
describe('ProductCard', () => {
it('renders product name and price', () => {
const wrapper = mount(ProductCard, {
props: {
product: {
id: '1',
name: 'Cloud Hosting',
price: 299,
category: 'infrastructure',
},
},
})
expect(wrapper.text()).toContain('Cloud Hosting')
expect(wrapper.text()).toContain('R299')
})
it('emits select event on click', async () => {
const wrapper = mount(ProductCard, {
props: { product: mockProduct },
})
await wrapper.find('.product-card').trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
expect(wrapper.emitted('select')![0]).toEqual([mockProduct.id])
})
})
Build Optimisation with Vite
Vite is not just a dev server. Its production build, powered by Rollup, provides tree-shaking, code splitting, CSS extraction, and asset hashing out of the box. But there are several optimisations worth configuring explicitly:
- Manual chunk splitting: Group vendor libraries into stable chunks that benefit from long-term caching. Separate Vue, your component library, and heavy dependencies like chart libraries into their own chunks.
- CSS code splitting: Vite extracts CSS per async chunk by default, so each route only loads the styles it needs.
- Image optimisation: Use
vite-plugin-imageminto compress images at build time rather than serving unoptimised assets. - Bundle analysis: Use
rollup-plugin-visualizerto identify oversized dependencies. We have caught 500KB+ libraries that could be replaced with 5KB alternatives.
Your production bundle size directly impacts user experience. At Pepla, we set a performance budget of 200KB gzipped for the initial JavaScript payload on every Vue project. If a dependency pushes us over budget, we find an alternative or lazy-load it.
Vue's strength is that it gets out of your way. It does not impose an opinionated project structure, a specific state management pattern, or a mandatory build tool. This flexibility is also its risk: without discipline, Vue projects can become inconsistent and difficult to maintain. The patterns in this article are the guardrails we use at Pepla to keep Vue projects clean, performant, and scalable as they grow from prototypes into production systems that serve real users every day.




