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.
Table of Contents
- 1. What Vue 3 patterns really solve
- 2. Composition API: using setup(), ref and reactive correctly
- 3. Composables: extracting and reusing logic
- 4. defineModel and prop patterns for clean component APIs
- 5. Pinia: reactive stores without boilerplate
- 6. Performance patterns: computed, shallowRef and v-memo
- 7. Suspense and async components for a better UX
- 8. Common mistakes and how to spot them
- 9. Vue 3 patterns in direct comparison
- 10. Summary
- 11. FAQ
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.