Reusable Logic Without Copy-Paste
Copy-paste between Vue components is a silent maintenance problem: when the same fetch logic, the same validation code, or the same scroll behavior is duplicated across ten components, you end up with a system that has to be touched ten times for every bug fix. Vue Composables solve this problem structurally by encapsulating reactive logic in useX functions that snap together like building blocks.
Table of Contents
- 1. Why Vue Composables structurally prevent copy-paste
- 2. The useX pattern: naming conventions and file structure
- 3. Encapsulating reactivity: using ref, reactive, and computed correctly
- 4. Async and fetch: useFetch as a universal composable pattern
- 5. Lifecycle hooks in composables: onMounted, onUnmounted, and watch
- 6. Composables with parameters: reactive arguments and options objects
- 7. provide/inject as a composable extension for component trees
- 8. Anti-patterns: what is not a good composable
- 9. Composables side by side: mixins, helpers, and composables
- 10. Summary
- 11. FAQ
1. Why Vue Composables structurally prevent copy-paste
In Vue 2, the main tool for logic reuse was the mixin. Mixins had a fundamental problem: their origin was not visible in the component. A property like isLoading could come from a mixin, from the component itself, or from both at once through a naming conflict, with the last-defined value winning. Vue Composables solve this problem by encapsulating logic as normal JavaScript functions that are called inside setup(). The call site is always explicit, and the origin of every returned value is immediately traceable.
A Vue Composable is technically a function that uses Vue reactivity primitives (ref, reactive, computed, watch) and optionally lifecycle hooks (onMounted, onUnmounted), and returns its reactive state values and methods as an object. Crucially, this function may only be called inside a reactive context, meaning in setup(), in another composable, or in <script setup>. Outside this context, lifecycle hooks do not work, and inject() fails. Understanding this is the foundation of all Vue Composable Patterns.
The concrete benefit shows up when you analyze a codebase: projects that consistently use composables have measurably fewer duplicated logic blocks. A usePagination composable is written once and used by every list page. A bug in the page-count calculation gets fixed in one place, not searched for across twelve components and forgotten in seven of them.
2. The useX pattern: naming conventions and file structure
The Vue community has settled on the use prefix convention, familiar from React Hooks. A Vue Composable is called useCounter, useFetch, useScroll, never counterLogic or fetchHelper. This prefix signals that the function uses reactive primitives and may only be called inside a reactive context. Volar (the official Vue language server) recognizes the prefix and issues warnings when a composable is called outside setup().
The file structure follows a simple convention: composables live in src/composables/ and are named after their exported function, such as useFetch.ts, useAuth.ts, useMediaQuery.ts. More complex domains get subfolders: src/composables/cart/useCartItems.ts. Each file exports exactly one main function as a named export, never a default export. This makes tree-shaking easier and makes auto-imports with Vite/Nuxt trivial. For cross-domain Vue Composable Patterns, the rule is: general-purpose composables like useDebounce go into src/composables/utils/, domain-specific ones go directly into the domain.
The return object of a composable should always be plain, not a reactive object as its root, but individual ref values and methods. This enables destructuring in the component: const { data, loading, error } = useFetch(url). A composable that returns a single reactive object forces the caller to use toRefs() for destructuring, which is unnecessary complexity.
// src/composables/usePagination.ts
import { ref, computed } from 'vue'
// Generic pagination composable, works with any list
export function usePagination(totalItems: number, itemsPerPage = 10) {
const currentPage = ref(1)
const perPage = ref(itemsPerPage)
const totalPages = computed(() =>
Math.ceil(totalItems / perPage.value)
)
const offset = computed(() =>
(currentPage.value - 1) * perPage.value
)
function goToPage(page: number) {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
function nextPage() { goToPage(currentPage.value + 1) }
function prevPage() { goToPage(currentPage.value - 1) }
function resetPage() { currentPage.value = 1 }
return {
currentPage,
perPage,
totalPages,
offset,
goToPage,
nextPage,
prevPage,
resetPage,
}
}
3. Encapsulating reactivity: using ref, reactive, and computed correctly
The most common design question for Vue Composables: ref or reactive? The rule of thumb: ref for scalar values (string, number, boolean, array), reactive for cohesive state objects where you never want to replace the root object. In practice, ref dominates in composables because return values can be destructured as individual refs without losing reactivity. reactive objects lose their reactivity on destructuring unless you use toRefs().
computed properties are the tool for derived state in Vue Composable Patterns. They are only recalculated when their dependencies change, and they cache the result in between. This is fundamentally different from a plain method, which re-executes on every template render. A composable that uses computed for expensive calculations is automatically more performant than one that returns methods. Writable computeds, with a separate get and set, are an advanced pattern for two-way data binding in composables, for example for form state synchronization with a store.
An important aspect of reactivity encapsulation: composables should never access the DOM directly. When a Vue Composable needs to interact with DOM elements, for example for scroll position, a ResizeObserver, or intersection detection, it receives a Ref<HTMLElement | null> as a parameter. The template binds the element via the ref attribute and passes it to the composable. This keeps the composable testable without a DOM environment.
4. Async and fetch: useFetch as a universal composable pattern
The useFetch composable is the textbook example of Vue Composable Patterns because it shows how to encapsulate loading, error, and success states that would otherwise be duplicated in every component. The basic pattern: three refs (data, loading, error), an async fetch function that manages these refs, and an immediate call inside onMounted. Once you encapsulate this pattern in a composable, you never write the same try-catch block in a component again.
Advanced Vue Composable variants of the fetch pattern support reactive URLs: when the URL is a Ref<string> or ComputedRef<string>, a watch can react to the URL and refetch automatically. This lets components such as product detail pages automatically load new data when the product ID changes, without a watch in the component itself. The composable manages the entire async lifecycle internally; the component only ever sees data, loading, and error.
// src/composables/useFetch.ts
import { ref, watch, type Ref } from 'vue'
interface UseFetchOptions {
immediate?: boolean // fetch on mount (default: true)
initialData?: unknown // initial value for data ref
}
// Universal fetch composable, works with reactive or static URLs
export function useFetch<T>(
url: string | Ref<string>,
options: UseFetchOptions = {}
) {
const { immediate = true, initialData = null } = options
const data = ref<T | null>(initialData as T | null)
const loading = ref(false)
const error = ref<Error | null>(null)
async function execute() {
const resolvedUrl = typeof url === 'string' ? url : url.value
loading.value = true
error.value = null
try {
const response = await fetch(resolvedUrl)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json() as T
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
loading.value = false
}
}
// Re-fetch when URL changes (only if URL is reactive)
if (typeof url !== 'string') {
watch(url, execute, { immediate })
} else if (immediate) {
execute()
}
return { data, loading, error, execute }
}
5. Lifecycle hooks in composables: onMounted, onUnmounted, and watch
Lifecycle hooks in Vue Composables register themselves on the currently active component instance. This means: a composable that calls onMounted attaches that hook to the component that called the composable. This is not a bug, it is the intended design. It enables composables like useEventListener, which register an event listener in onMounted and cleanly remove it again in onUnmounted, without the component itself having to worry about cleanup.
The cleanup pattern is one of the most important Vue Composable Patterns overall. Every resource a composable creates in onMounted, event listeners, timers, WebSocket connections, ResizeObservers, must be cleaned up in onUnmounted. Alternatively, watchEffect returns a cleanup function that is automatically called before the next effect run and on unmount. Composables that clean up properly prevent memory leaks in single-page applications that run for hours at a time.
watch in a composable should always default to { immediate: false } to avoid unintended initial runs. watchEffect, on the other hand, runs immediately and tracks dependencies automatically; it is the right tool when you want to automatically track all reactive dependencies of an effect without enumerating them explicitly. For explicit sources with clear logic, watch is preferable.
6. Composables with parameters: reactive arguments and options objects
Well-designed Vue Composables accept both static and reactive arguments. The Vue community convention for this: type parameters as MaybeRef<T>, which corresponds to T | Ref<T>, and internally use toValue() (Vue 3.3+) or unref() to extract the current value. This lets a composable be called with useFetch('/api/products') just as well as with useFetch(productUrl), where productUrl is a computed. This makes composables maximally flexible.
Options objects as a second argument are the right pattern as soon as a Vue Composable has more than two parameters. Instead of useDebounce(value, 300, true, false), you use useDebounce(value, { delay: 300, leading: true, trailing: false }). This makes call sites self-documenting and allows optional parameters with sensible defaults, without depending on argument position. TypeScript interfaces for options objects should be defined and exported in the same file, so that users of the composable can write type-safe configuration.
7. provide/inject as a composable extension for component trees
Vue's provide/inject pattern is not a replacement for composables, but a sensible extension: composables that need to share reactive state across a component tree without prop drilling combine provide and inject with the Vue Composable pattern. The classic example: a useTheme composable that is called in a root component and provides theme state with provide(ThemeKey, { theme, toggleTheme }). Child components call a second useTheme() composable, which internally calls inject(ThemeKey).
The injection key is crucial for type-safe injections. In TypeScript you use InjectionKey<T> from Vue to specify the type of the injected value: const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme'). This way TypeScript knows what type inject(ThemeKey) returns, no manual casting needed. This Vue Composable Pattern is the type-safe alternative to global state for things like authentication context, i18n, or app-wide UI state.
// src/composables/useTheme.ts
import { ref, provide, inject, type InjectionKey } from 'vue'
type Theme = 'light' | 'dark'
interface ThemeContext { theme: ReturnType<typeof ref<Theme>>; toggle: () => void }
// Injection key with explicit type, TypeScript knows what inject() returns
const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
// Root composable: call in App.vue to provide theme
export function useThemeProvider() {
const theme = ref<Theme>('light')
const toggle = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' }
provide(ThemeKey, { theme, toggle })
return { theme, toggle }
}
// Consumer composable: call in any child component
export function useTheme() {
const ctx = inject(ThemeKey)
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
return ctx
}
8. Anti-patterns: what is not a good composable
The most common anti-pattern with Vue Composables is the "God Composable": a single composable that combines fetch logic, state management, validation, and formatting in one function. Such composables are hard to test because all aspects have to be tested together. The countermeasure: keep composables small and compose them. A useProductForm internally calls useFetch, useFormValidation, and useToast, it coordinates, but does not implement anything twice.
A second anti-pattern is direct store access deep inside utility composables. If useFormatPrice internally calls useCurrencyStore(), it is no longer testable without a full store setup. Composables designed as pure utilities should receive state as a parameter rather than fetching it internally. The Vue Composable design principle here: utility composables are stateless or manage their own local state. Domain composables are allowed to access stores, but are correspondingly harder to test in isolation.
The third anti-pattern: a composable that manipulates the DOM directly instead of receiving a template ref as a parameter. document.getElementById('header') inside a composable means the composable only works in a browser environment and not in SSR or tests without JSDOM. The correct pattern passes el: Ref<HTMLElement | null> as a parameter and checks internally for el.value !== null before every DOM operation.
9. Composables side by side: mixins, helpers, and composables
The structural difference between the three approaches to logic reuse is decisive for choosing the right Vue Composable pattern.
| Criterion | Vue 2 Mixin | Utility Function | Vue Composable |
|---|---|---|---|
| Origin visible in template | No | N/A | Yes, via destructuring |
| Reactivity | Implicit | None | Explicit and encapsulable |
| Lifecycle hooks | Yes, hidden | No | Yes, explicit and isolated |
| Testability | Complex | Easy | Good, with mountComposable |
| Naming conflicts | Common | None | Avoidable via aliasing |
The table makes it clear: Vue Composables combine the strengths of both predecessors and eliminate their weaknesses. Mixins are still supported in Vue 3, but officially marked as a legacy feature. For new Vue 3 projects, the rule is: convert mixins whenever the opportunity arises. Utility functions remain useful for stateless, non-reactive logic, formatting, calculations, validators without reactive state.
Mironsoft
Vue 3 architecture, composable design, and frontend engineering
Vue composable architecture for your project?
We analyze existing Vue codebases, identify copy-paste patterns, and refactor them into cleanly structured Vue Composables, with full test coverage and TypeScript typing.
Composable audit
Identify existing mixins and duplicated logic, and plan a migration path
Refactoring
Convert mixins into type-safe Vue Composables with full test coverage
Architecture review
Document composable structure, naming, and composability for your team
10. Summary
Vue Composable Patterns are the structural answer to copy-paste in Vue 3 projects. The useX prefix signals reactive context and enables tool-assisted warnings on misuse. Individual ref values as return values enable clean destructuring. Async composables with data, loading, and error encapsulate the entire lifecycle of an API call. Lifecycle hooks in composables register themselves on the calling component and enable clean resource cleanup. The provide/inject pattern extends composables for component-tree-wide state without prop drilling.
The biggest lever lies in consistency: a project that consistently relies on Vue Composables has a single place for every unit of logic. New developers understand the structure immediately, because composables are normal functions, no framework magic, no implicit merge algorithm like mixins. Testability, maintainability, and extensibility improve in proportion to how consistently the composable pattern is applied.
Vue Composable Patterns: The Essentials at a Glance
useX convention
Always the use prefix, named export, one file per composable. Return value: individual refs, no reactive root objects.
Encapsulating reactivity
ref for scalars and arrays, computed for derived state. MaybeRef parameters for maximum flexibility.
Lifecycle & cleanup
onMounted/onUnmounted in the composable register hooks on the calling component. Clean up every resource properly.
Avoiding anti-patterns
No God Composables, no direct DOM access without a ref parameter, no deeply embedded store dependencies in utilities.