‹ Back to Blog Software Engineering

Vue.js 3: Building Reactive Interfaces That Scale

April 8, 2026 · 9 min read
JavaScript code on screen

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)
)
Reactive code patterns on screen

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>
Code on a widescreen monitor

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:

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.

Need help with this?

Pepla builds reactive Vue.js applications from client portals to full platforms. Let us build yours.

Get in Touch

Contact Us

Schedule a Meeting

Book a free consultation to discuss your project requirements.

Book a Meeting ›

Let's Connect