50 Vue 3 Patterns for Productive Frontends
AI generated
<v/>
{ }
Vue 3 · Composition API · TypeScript · Pinia
50 Vue 3 Patterns for Productive Frontends
from composables to performant reactivity patterns

Anyone who uses Vue 3 with the same patterns as Vue 2 is giving up the potential of the Composition API. Composables, defineModel, Suspense and reactive stores with Pinia replace fragile Options API constructs with maintainable, testable and type-safe Vue 3 patterns that scale even in large teams.

20 min read Composition API · Composables · defineModel · Pinia · Suspense Vue 3.4+ · Vite · TypeScript

1. What Vue 3 patterns really solve

A Vue 3 pattern is not a stylistic choice, it is a proven solution structure for a recurring problem in frontend development. The difference from an ad-hoc solution lies in the fact that the pattern is deliberately designed for reusability, testability and type safety. Vue 3 brought a completely new paradigm with the Composition API that makes these patterns possible in the first place, but it only unfolds its potential when applied consistently instead of simply translating old Options API thinking.

In practice, teams often use Vue 3 but still work with data(), computed: and methods: in the Options API because it feels familiar. That is technically correct but gives up everything Vue 3 brings in terms of composition. A Vue 3 pattern like the composable solves the fundamental problem of the Options API: logic scattered across data, computed, methods and lifecycle hooks is inseparably bound to the component and cannot easily be reused. The following sections show the most important Vue 3 patterns, from the setup foundation through reactive stores to performance optimizations.

2. Composition API: using setup(), ref and reactive correctly

The foundation of all modern Vue 3 patterns is the Composition API with its setup() function, which since Vue 3.2 can be used even more simply through the <script setup> syntactic sugar. The first important decision is ref versus reactive. ref wraps any value, primitives as well as objects, in a reactive container accessed through .value. reactive makes an object directly reactive, without .value, but loses reactivity when its properties are destructured. The Vue 3 pattern for most situations: ref for scalar values and state that gets swapped out, reactive for tightly related object structures such as form data.

The <script setup> pattern is the recommended syntax in Vue 3.2+. Everything declared at the top level is automatically available in the template, no explicit return is required. defineProps and defineEmits are compiler macros that enable type-safe props and events without imports. The pattern withDefaults(defineProps<Props>(), {...}) combines TypeScript type safety with default values in a single, readable declaration. A common mistake: declaring reactive state outside setup() or <script setup>, which causes refs to lose their reactivity connection to the component instance.


<!-- ProductCard.vue: Composition API with script setup -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Product } from '@/types'

// Props with TypeScript types and defaults
interface Props {
  product: Product
  currency?: string
  showStock?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  currency: 'EUR',
  showStock: true,
})

// Emits, typed for autocomplete and runtime validation
const emit = defineEmits<{
  addToCart: [productId: number, quantity: number]
  wishlist: [productId: number]
}>()

// Local state: ref for primitives, reactive for form groups
const quantity = ref(1)
const isLoading = ref(false)

// Derived state, computed caches automatically
const formattedPrice = computed(() =>
  new Intl.NumberFormat('de-DE', { style: 'currency', currency: props.currency })
    .format(props.product.price)
)

const canAddToCart = computed(() =>
  !isLoading.value && props.product.stock > 0 && quantity.value >= 1
)

// Lifecycle
onMounted(() => {
  console.log('ProductCard mounted for:', props.product.id)
})

function handleAddToCart() {
  if (!canAddToCart.value) return
  emit('addToCart', props.product.id, quantity.value)
}
</script>

Another important Vue 3 pattern is the consistent separation of reactive state from non-reactive constants. Values that never change should be declared with const outside the reactive layer, which reduces the overhead of the reactivity system. shallowRef and shallowReactive are specialized variants for large data structures where only the top level needs to be reactive. A shallowRef holding a large array only triggers reactivity when the array itself is replaced, not when its elements are mutated. This is a decisive Vue 3 pattern for lists with many entries.

3. Composables: extracting and reusing logic

The most important architectural Vue 3 pattern is the composable. A composable is a function prefixed with use that internally uses the Composition API and returns reactive state as well as methods. This makes logic that used to be bound to a component fully reusable, without mixins, without inheritance and without the hidden collisions of mixin namespacing. A useFetch() composable encapsulates loading state, error handling and the actual result in a single function that can be called in any component.

The Vue 3 pattern for clean composables: they should be self-contained and only return what the caller needs. Reactive state inside a composable is private unless it is explicitly returned. This allows the same composable to be called multiple times within the same component, with each call getting its own isolated state. By contrast, Pinia stores share their state between all consumers, which is a fundamental difference that determines the choice between composable and store: local component state belongs in the composable, global application state in the store.


// composables/useFetch.ts: Reusable data-fetching composable
import { ref, watch, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  isLoading: Ref<boolean>
  execute: () => Promise<void>
}

export function useFetch<T>(url: Ref<string> | string): UseFetchReturn<T> {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const isLoading = ref(false)

  // Abort controller to cancel in-flight requests on URL change
  let controller: AbortController | null = null

  async function execute() {
    const resolvedUrl = typeof url === 'string' ? url : url.value
    controller?.abort()
    controller = new AbortController()

    isLoading.value = true
    error.value = null

    try {
      const res = await fetch(resolvedUrl, { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
      data.value = await res.json() as T
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        error.value = err as Error
      }
    } finally {
      isLoading.value = false
    }
  }

  // Re-fetch automatically when URL changes
  if (typeof url !== 'string') {
    watch(url, execute, { immediate: true })
  } else {
    execute()
  }

  return { data, error, isLoading, execute }
}

4. defineModel and prop patterns for clean component APIs

Vue 3.4 introduced defineModel() as a stable compiler macro, one of the most important new Vue 3 patterns for form components. Previously, a v-model-compatible component required the explicit declaration of a modelValue prop and an update:modelValue emit, plus manual wiring through computed getters and setters. defineModel() encapsulates exactly this pattern in a single line and returns a reactive ref that can be read and written directly, with Vue handling the props-down-events-up cycle behind the scenes.

For more complex component APIs, the Vue 3 pattern with multiple named models is particularly valuable. A modal component can expose v-model:visible and v-model:activeTab at the same time, each with its own type and its own default value. The props typing pattern with defineProps<Props>() combines TypeScript interfaces with Vue's runtime validation. For props that can either be a primitive value or undefined, PropType from Vue's type definitions is used to correctly express complex types such as union types and generic interfaces.

5. Pinia: reactive stores without boilerplate

Pinia is the official state manager for Vue 3 and embodies the modern Vue 3 pattern for global application state. Unlike Vuex 4, there are no mutations, no namespaced module nesting and no explicit commit boilerplate. A Pinia store is conceptually nothing more than a composable with persisted, shared state: state is defined with ref or reactive, getters with computed and actions with plain functions. The Vue 3 pattern for Pinia favors the setup store variant over the options store variant because it uses the same mental model as <script setup> and offers full TypeScript inference.

An important Vue 3 pattern for Pinia stores: actions should be the only place where state is mutated. Direct state mutations from components technically work but violate the principle of traceable state transitions. With the Pinia devtools plugin in Vue Devtools, every state transition is visible with action name, before and after state and timestamp, and that is only useful if mutations flow through actions. Pinia's store-persist plugin makes it possible to persist selected store properties in localStorage, a typical Vue 3 pattern for user preferences such as theme and language selection.


// stores/cart.ts: Pinia setup store
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { CartItem, Product } from '@/types'

export const useCartStore = defineStore('cart', () => {
  // State, plain refs, fully typed
  const items = ref<CartItem[]>([])
  const couponCode = ref<string | null>(null)

  // Getters, computed, cached automatically
  const totalItems = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  const subtotal = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  const isEmpty = computed(() => items.value.length === 0)

  // Actions, the only place state should be mutated
  function addItem(product: Product, quantity = 1) {
    const existing = items.value.find(i => i.id === product.id)
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({ ...product, quantity })
    }
  }

  function removeItem(productId: number) {
    items.value = items.value.filter(i => i.id !== productId)
  }

  function clearCart() {
    items.value = []
    couponCode.value = null
  }

  return { items, couponCode, totalItems, subtotal, isEmpty, addItem, removeItem, clearCart }
})

6. Performance patterns: computed, shallowRef and v-memo

Vue's reactivity system is automatic, but not magic. Reactivity used incorrectly leads to unnecessary re-renders that show up as measurable UI sluggishness. The fundamental Vue 3 pattern for performance: express all derived values with computed instead of methods. A computed value is only recalculated when its dependencies actually change and it is used in the template. A method, on the other hand, runs again on every render cycle, even if the inputs are unchanged. For large lists, v-memo is the specialized Vue 3 pattern: it memoizes a template subtree and only re-renders it when the specified dependencies change.

shallowRef is the Vue 3 pattern for large data structures that are rarely mutated deeply, such as a complete API response with a hundred products. With a normal ref, Vue tracks every nested property reactively; for a deep structure, that means significant initialization overhead. A shallowRef only makes the reference itself reactive, not its contents. When the entire array is replaced, reactivity triggers correctly. Individual elements can be made reactive manually with triggerRef() when needed. The Vue 3 pattern for long lists combines shallowRef with virtualized rendering through vue-virtual-scroller or TanStack Virtual.

7. Suspense and async components for a better UX

Vue 3's <Suspense> component solves a classic frontend problem with an elegant Vue 3 pattern: the declarative handling of asynchronous component initializations. When a component contains an await in its setup(), or uses an async composable that returns a promise, it can be wrapped in <Suspense>. As long as the promise is pending, Suspense renders the #fallback slot; once it resolves, it shows the #default slot. This replaces the previously common pattern of local isLoading booleans in every component with a central, declarative mechanism.

Async components with defineAsyncComponent() are the Vue 3 pattern for code splitting at the component level. Vite and Rollup automatically detect dynamic imports and generate separate chunks for each async component. The result: the initial bundle size stays small, heavy components such as rich text editors, chart libraries or modals are only loaded once they actually need to be rendered. defineAsyncComponent accepts an options object with loadingComponent, errorComponent and delay, which rounds out the Vue 3 pattern for professional lazy loading.


<!-- AsyncDashboard.vue: Suspense + defineAsyncComponent pattern -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// Heavy chart component, loaded on demand, separate bundle chunk
const SalesChart = defineAsyncComponent({
  loader: () => import('@/components/SalesChart.vue'),
  loadingComponent: () => import('@/components/Skeleton.vue'),
  errorComponent: () => import('@/components/ErrorCard.vue'),
  delay: 200,    // Show loading only after 200ms (avoid flash for fast loads)
  timeout: 8000, // Show error after 8s
})
</script>

<template>
  <!-- Suspense handles async setup() in child components -->
  <Suspense>
    <template #default>
      <div class="grid grid-cols-2 gap-6">
        <SalesChart :period="selectedPeriod" />
        <RevenueTable :limit="10" />
      </div>
    </template>
    <template #fallback>
      <div class="grid grid-cols-2 gap-6">
        <SkeletonCard class="h-64" />
        <SkeletonCard class="h-64" />
      </div>
    </template>
  </Suspense>
</template>

8. Common mistakes and how to spot them

The most common mistake in Vue 3 is accidentally losing reactivity through destructuring. When a reactive object is destructured, const { name, price } = reactive({name: 'X', price: 10}), the extracted properties become ordinary variables, no longer reactive refs. Changes to name or price are no longer picked up by the template. The correct Vue 3 pattern: use toRefs() or toRef() when destructuring properties of a reactive object while keeping reactivity intact. In composables that return a reactive object, toRefs() in the return statement is mandatory so the caller can safely destructure it.

A second common mistake is mutating props directly in the child component. Vue's props-down-events-up principle states: data flows down through props, changes flow up through emits. Mutating an array prop directly creates a violation of this data flow that leads to hard-to-track state inconsistencies. The correct Vue 3 pattern: create a local copy of the prop with ref(props.items) or use defineModel(), which implements the props update cycle correctly. A third classic mistake: using watch instead of watchEffect when all dependencies should be tracked automatically. watch requires explicit source declaration; watchEffect automatically tracks all reactive values read inside the callback.

9. Vue 3 patterns in direct comparison

Many tasks in Vue can be solved in several ways, with substantial differences in maintainability and correctness. Choosing the right Vue 3 pattern has a direct impact on the longevity of the codebase.

Task Options API / outdated Vue 3 pattern Benefit
Sharing logic Mixin with collision risk Composable (useFoo) Isolated, testable, no naming conflicts
Two-way binding modelValue + emit manually defineModel() One-liner, type-safe, Vue-internal
Global state Vuex with mutations Pinia setup store No boilerplate, full TS inference
Async loading isLoading boolean per component Suspense + async setup Declarative, central, reusable
Code splitting Everything in one bundle defineAsyncComponent Lazy load, smaller initial bundle

The "Vue 3 pattern" column shows more than just modern syntax. Each of the listed patterns solves a concrete maintainability problem that the old variant carried with it. Mixins silently collided on identically named properties. Vuex mutations made typing cumbersome. Manual isLoading flags led to inconsistent error handling across components. Anyone who applies the Vue 3 patterns consistently automatically gets more testable, more type-safe and more maintainable code.

Mironsoft

Vue 3 frontend development, architecture and performance

A Vue 3 frontend that scales in large teams?

We analyze existing Vue applications, identify fragile patterns and migrate them to proven Vue 3 patterns, with Composition API, Pinia and full TypeScript coverage.

Code review

Analysis for outdated Options API patterns, missing typing and performance weaknesses

Migration

Vue 2 to Vue 3, Vuex to Pinia, mixins to composables, clean and incremental

Architecture

Component design, store structure and composable libraries for growing teams

10. Summary

The most important Vue 3 patterns for productive frontends always solve the same fundamental problem: code written without clear patterns turns into a black box in large teams. The Composition API with <script setup> replaces the scattered options object with focused, cohesive code. Composables extract and share logic without mixin collisions. defineModel() turns two-way binding in form components into a one-liner. Pinia's setup stores provide reactive global state without Vuex boilerplate. Suspense and async components make lazy loading and loading states declarative instead of imperative.

The biggest lever lies in applying these patterns consistently across the entire codebase. A component using a modern composable pattern next to another using the Options API and mixins creates uneven maintainability standards within the team. TypeScript across every layer, props, emits, composables and stores, makes refactoring safe and partly makes documentation unnecessary, because the types document themselves. Anyone who applies Vue 3 patterns consistently ends up with a codebase that grows with the team and its requirements instead of breaking under them.

Vue 3 Patterns for Productive Frontends: The Essentials at a Glance

Composition API

<script setup> with ref, computed and defineProps, mandatory in new components. No Options API in new code.

Composables instead of mixins

useFoo() functions encapsulate reusable logic without naming collisions, with full TypeScript inference.

Pinia for global state

Setup store with ref, computed and actions, no Vuex, no mutations, full TS support out of the box.

Performance

shallowRef for large data, v-memo for lists, defineAsyncComponent for code splitting at the component level.

11. FAQ: Vue 3 Patterns for Productive Frontends

1What is a Vue 3 pattern?
A proven solution structure for a recurring frontend problem, using the Composition API deliberately for reusability, testability and type safety.
2Composables instead of mixins?
Mixins collide on identical names and hide where state comes from. Composables are explicit, isolated and fully typeable.
3ref vs. reactive?
ref for scalar values, safe to destructure. reactive for tightly bound objects, but never destructure without toRefs().
4Pinia vs. composable?
Pinia for global shared state (cart, auth). Composable for local or per-instance isolated state.
5defineModel() explained?
A compiler macro that automatically registers a modelValue prop and an update:modelValue emit. Returns a writable ref, handling props-down-events-up internally.
6What does Suspense bring?
Declarative loading handling for async setup(). Fallback slot as long as the promise is pending, no more isLoading boolean in every component.
7Loss of reactivity on destructuring?
Use toRefs() when destructuring reactive objects. Mandatory in composables at the return statement.
8When to use shallowRef?
For large API responses and data structures that are swapped out as a whole. Prevents expensive deep tracking.
9watch vs. watchEffect?
watch for explicit sources and old/new values. watchEffect automatically tracks all reactive values used in the callback.
10defineAsyncComponent for code splitting?
A dynamic import produces a separate chunk. Loaded on demand, with loadingComponent and errorComponent for professional UX.