Vue 3 Composable Design Patterns: Building Reusable Logic Like a Senior Developer
Why Composables Are the Core of Vue 3 Architecture
In 2026, the Vue 3 ecosystem has fully embraced the Composition API. Composables are no longer an "optional approach" — they are the canonical way to build maintainable, testable, and reusable logic. From Pinia to VueUse, from Nuxt 3 to Vuetify 3, the entire ecosystem runs on Composables as its foundation.
| Feature | Composable | Mixin | Renderless Component | Custom Directive |
|---|---|---|---|---|
| Logic Reuse | ✅ Flexible composition | ⚠️ Implicit merge | ✅ Explicit passing | ❌ DOM only |
| Type Inference | ✅ Full TS support | ❌ Type loss | ⚠️ Manual annotation | ❌ No types |
| Name Conflicts | ✅ Explicit destructuring | ❌ Implicit override | ✅ Props isolation | ❌ Global risk |
| Testability | ✅ Pure function testing | ❌ Context coupling | ⚠️ Requires mount | ❌ Requires DOM |
| Tree-shaking | ✅ Native support | ❌ Whole import | ⚠️ Component granularity | ❌ Global registration |
| Learning Curve | Medium | Low | High | Low |
Conclusion: In 2026, Composables are the optimal solution for logic reuse — no exceptions.
Pattern 1: useCounter — State Management Pattern
Problem solved: Share simple counter logic across components, avoiding repeated ref and operation method definitions.
import { ref, computed, type Ref, type ComputedRef } from 'vue'
interface UseCounterOptions {
min?: number
max?: number
step?: number
initialValue?: number
}
interface UseCounterReturn {
count: Ref<number>
isMin: ComputedRef<boolean>
isMax: ComputedRef<boolean>
increment: () => void
decrement: () => void
reset: () => void
set: (value: number) => void
}
export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
const { min = -Infinity, max = Infinity, step = 1, initialValue = 0 } = options
const count = ref(initialValue)
const isMin = computed(() => count.value <= min)
const isMax = computed(() => count.value >= max)
const clamp = (value: number): number => Math.min(max, Math.max(min, value))
const increment = (): void => {
count.value = clamp(count.value + step)
}
const decrement = (): void => {
count.value = clamp(count.value - step)
}
const reset = (): void => {
count.value = clamp(initialValue)
}
const set = (value: number): void => {
count.value = clamp(value)
}
return { count, isMin, isMax, increment, decrement, reset, set }
}
Usage example:
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'
const { count, isMin, isMax, increment, decrement } = useCounter({
min: 0,
max: 99,
step: 1,
initialValue: 0
})
</script>
<template>
<button :disabled="isMin" @click="decrement">-</button>
<span>{{ count }}</span>
<button :disabled="isMax" @click="increment">+</button>
</template>
Pattern 2: useFetch / useAsyncData — Async Data Pattern
Problem solved: Unify management of async request loading, error, and data states, eliminating repeated try-catch-loading boilerplate in every component.
import { ref, shallowRef, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
interface UseFetchOptions<T> {
immediate?: boolean
initialData?: T
refetch?: boolean
onSuccess?: (data: T) => void
onError?: (error: Error) => void
}
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
isReady: Ref<boolean>
execute: () => Promise<T | null>
refresh: () => Promise<T | null>
}
export function useFetch<T>(
url: MaybeRefOrGetter<string>,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { immediate = true, initialData, onSuccess, onError } = options
const data = shallowRef<T | null>(initialData ?? null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
const isReady = ref(false)
const execute = async (): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const resolvedUrl = toValue(url)
const response = await fetch(resolvedUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
data.value = await response.json()
isReady.value = true
onSuccess?.(data.value as T)
return data.value as T
} catch (err) {
const errorInstance = err instanceof Error ? err : new Error(String(err))
error.value = errorInstance
onError?.(errorInstance)
return null
} finally {
isLoading.value = false
}
}
if (immediate) {
execute()
}
return { data, error, isLoading, isReady, execute, refresh: execute }
}
Usage example:
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
const { data: users, isLoading, error, refresh } = useFetch<User[]>('/api/users', {
immediate: true,
onSuccess: (data) => console.log(`Loaded ${data.length} users`)
})
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="refresh">Refresh</button>
</template>
Pattern 3: useFormValidation — Form Validation Pattern
Problem solved: Extract form validation logic from components, implementing declarative validation rules with unified error management.
import { ref, reactive, computed, type Ref } from 'vue'
type Validator = (value: any) => string | true
type FieldValidators = Record<string, Validator[]>
interface FormState {
[key: string]: any
}
interface UseFormValidationReturn {
formState: FormState
errors: Ref<Record<string, string>>
isValid: Ref<boolean>
isDirty: Ref<boolean>
validate: () => boolean
validateField: (field: string) => boolean
reset: () => void
touched: Ref<Record<string, boolean>>
}
export function useFormValidation(
initialState: FormState,
validators: FieldValidators
): UseFormValidationReturn {
const formState = reactive<FormState>({ ...initialState })
const errors = ref<Record<string, string>>({})
const touched = ref<Record<string, boolean>>({})
const isDirty = computed(() => {
return Object.keys(initialState).some(
(key) => formState[key] !== initialState[key]
)
})
const isValid = computed(() => {
return Object.keys(errors.value).every(
(key) => !errors.value[key]
)
})
const validateField = (field: string): boolean => {
const fieldValidators = validators[field]
if (!fieldValidators) return true
touched.value[field] = true
for (const validator of fieldValidators) {
const result = validator(formState[field])
if (result !== true) {
errors.value[field] = result
return false
}
}
errors.value[field] = ''
return true
}
const validate = (): boolean => {
let allValid = true
for (const field of Object.keys(validators)) {
if (!validateField(field)) {
allValid = false
}
}
return allValid
}
const reset = (): void => {
Object.assign(formState, initialState)
errors.value = {}
touched.value = {}
}
return { formState, errors, isValid, isDirty, validate, validateField, reset, touched }
}
Usage example:
const { formState, errors, validate, isValid } = useFormValidation(
{ email: '', password: '' },
{
email: [
(v: string) => !!v || 'Email is required',
(v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || 'Invalid email format'
],
password: [
(v: string) => !!v || 'Password is required',
(v: string) => v.length >= 8 || 'Password must be at least 8 characters'
]
}
)
Pattern 4: useEventBus — Event Bus Pattern
Problem solved: Implement type-safe event communication between non-parent-child components, replacing Vue 2's $on/$off global event bus.
import { onUnmounted, type Ref } from 'vue'
type EventMap = Record<string, any>
interface EventBus<T extends EventMap> {
on: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
off: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
emit: <K extends keyof T & string>(event: K, payload: T[K]) => void
once: <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void) => void
}
export function createEventBus<T extends EventMap>(): EventBus<T> {
const listeners = new Map<string, Set<Function>>()
const on = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
if (!listeners.has(event)) {
listeners.set(event, new Set())
}
listeners.get(event)!.add(handler)
}
const off = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
listeners.get(event)?.delete(handler)
}
const emit = <K extends keyof T & string>(event: K, payload: T[K]): void => {
listeners.get(event)?.forEach((handler) => handler(payload))
}
const once = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
const wrappedHandler = (payload: T[K]) => {
handler(payload)
off(event, wrappedHandler as any)
}
on(event, wrappedHandler as any)
}
return { on, off, emit, once }
}
export function useEventBus<T extends EventMap>(bus: EventBus<T>): EventBus<T> {
const handlers = new Map<string, Function>()
const scopedOn = <K extends keyof T & string>(event: K, handler: (payload: T[K]) => void): void => {
handlers.set(event, handler)
bus.on(event, handler)
}
onUnmounted(() => {
handlers.forEach((handler, event) => {
bus.off(event as any, handler as any)
})
})
return { ...bus, on: scopedOn as any, off: bus.off, emit: bus.emit, once: bus.once }
}
Usage example:
interface AppEvents {
'user:login': { userId: string; token: string }
'cart:update': { itemId: string; quantity: number }
'notification:show': { message: string; type: 'success' | 'error' }
}
const bus = createEventBus<AppEvents>()
// Listen in Component A
const { on, emit } = useEventBus(bus)
on('user:login', ({ userId }) => {
console.log(`User ${userId} logged in`)
})
// Emit in Component B
emit('user:login', { userId: '123', token: 'abc' })
Pattern 5: useDebounce — Debounce Pattern
Problem solved: Unify debounce logic with support for reactive delay times and automatic timer cleanup.
import { ref, unref, onUnmounted, type MaybeRef } from 'vue'
interface UseDebounceOptions {
immediate?: boolean
}
interface UseDebounceReturn<T extends (...args: any[]) => any> {
debouncedFn: T
isPending: Ref<boolean>
cancel: () => void
flush: () => void
}
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay: MaybeRef<number>,
options: UseDebounceOptions = {}
): UseDebounceReturn<T> {
const { immediate = false } = options
let timeoutId: ReturnType<typeof setTimeout> | null = null
let lastArgs: any[] | null = null
const isPending = ref(false)
const cancel = (): void => {
if (timeoutId !== null) {
clearTimeout(timeoutId)
timeoutId = null
isPending.value = false
lastArgs = null
}
}
const flush = (): void => {
if (lastArgs !== null) {
fn(...lastArgs)
cancel()
}
}
const debouncedFn = ((...args: any[]) => {
lastArgs = args
cancel()
if (immediate && !timeoutId) {
fn(...args)
isPending.value = true
}
timeoutId = setTimeout(() => {
if (!immediate) {
fn(...args)
}
timeoutId = null
isPending.value = false
lastArgs = null
}, unref(delay))
}) as T
onUnmounted(cancel)
return { debouncedFn, isPending, cancel, flush }
}
Usage example:
const { debouncedFn: debouncedSearch } = useDebounce(
(keyword: string) => {
fetchResults(keyword)
},
300
)
debouncedSearch(inputValue)
Pattern 6: usePagination — Pagination Pattern
Problem solved: Unify pagination logic with page number calculation, boundary checking, and page navigation.
import { ref, computed, toValue, type Ref, type ComputedRef, type MaybeRefOrGetter } from 'vue'
interface UsePaginationOptions {
total: MaybeRefOrGetter<number>
pageSize?: MaybeRefOrGetter<number>
initialPage?: number
}
interface UsePaginationReturn {
currentPage: Ref<number>
totalPages: ComputedRef<number>
isFirstPage: ComputedRef<boolean>
isLastPage: ComputedRef<boolean>
offset: ComputedRef<number>
nextPage: () => void
prevPage: () => void
goToPage: (page: number) => void
pageSize: ComputedRef<number>
}
export function usePagination(options: UsePaginationOptions): UsePaginationReturn {
const { total, pageSize: pageSizeRef = 10, initialPage = 1 } = options
const currentPage = ref(initialPage)
const pageSize = computed(() => toValue(pageSizeRef))
const totalPages = computed(() => {
const totalItems = toValue(total)
return Math.max(1, Math.ceil(totalItems / pageSize.value))
})
const isFirstPage = computed(() => currentPage.value <= 1)
const isLastPage = computed(() => currentPage.value >= totalPages.value)
const offset = computed(() => (currentPage.value - 1) * pageSize.value)
const nextPage = (): void => {
if (!isLastPage.value) {
currentPage.value++
}
}
const prevPage = (): void => {
if (!isFirstPage.value) {
currentPage.value--
}
}
const goToPage = (page: number): void => {
currentPage.value = Math.min(Math.max(1, page), totalPages.value)
}
return { currentPage, totalPages, isFirstPage, isLastPage, offset, nextPage, prevPage, goToPage, pageSize }
}
Usage example:
const { currentPage, totalPages, nextPage, prevPage, offset, isFirstPage, isLastPage } = usePagination({
total: totalItems,
pageSize: 20
})
const { data } = useFetch(() => `/api/items?offset=${offset.value}&limit=20`)
Pattern 7: useInfiniteScroll — Infinite Scroll Pattern
Problem solved: Unify IntersectionObserver logic for infinite scrolling with automatic data loading.
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
interface UseInfiniteScrollOptions {
target: Ref<HTMLElement | null>
onLoadMore: () => Promise<void>
threshold?: number
rootMargin?: string
disabled?: Ref<boolean>
}
interface UseInfiniteScrollReturn {
isLoadingMore: Ref<boolean>
isFinished: Ref<boolean>
reset: () => void
}
export function useInfiniteScroll(options: UseInfiniteScrollOptions): UseInfiniteScrollReturn {
const {
target,
onLoadMore,
threshold = 0.1,
rootMargin = '100px',
disabled = ref(false)
} = options
const isLoadingMore = ref(false)
const isFinished = ref(false)
let observer: IntersectionObserver | null = null
const handleIntersect = async (entries: IntersectionObserverEntry[]): Promise<void> => {
const [entry] = entries
if (!entry.isIntersecting || isLoadingMore.value || isFinished.value || disabled.value) {
return
}
isLoadingMore.value = true
try {
await onLoadMore()
} catch {
isFinished.value = true
} finally {
isLoadingMore.value = false
}
}
const setupObserver = (): void => {
if (!target.value) return
observer = new IntersectionObserver(handleIntersect, {
threshold,
rootMargin
})
observer.observe(target.value)
}
const reset = (): void => {
isFinished.value = false
observer?.disconnect()
setupObserver()
}
onMounted(setupObserver)
onUnmounted(() => observer?.disconnect())
return { isLoadingMore, isFinished, reset }
}
Usage example:
<script setup lang="ts">
import { ref } from 'vue'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
const sentinel = ref<HTMLElement | null>(null)
const items = ref<Item[]>([])
const { isLoadingMore } = useInfiniteScroll({
target: sentinel,
onLoadMore: async () => {
const newItems = await fetchMoreItems(items.value.length)
if (newItems.length === 0) return
items.value.push(...newItems)
}
})
</script>
<template>
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<div ref="sentinel" />
<div v-if="isLoadingMore">Loading more...</div>
</template>
Testing Composables with Vitest
The core principle of testing Composables: execute within a setup context. Use the withSetup pattern from @vue/test-utils or a manual wrapper.
import { describe, it, expect, vi } from 'vitest'
import { createApp } from 'vue'
import { useCounter } from '@/composables/useCounter'
import { useFetch } from '@/composables/useFetch'
import { useFormValidation } from '@/composables/useFormValidation'
export function withSetup<T>(composable: () => T): [T, () => void] {
let result: T
const app = createApp({
setup() {
result = composable()
return () => null
}
})
const mountPoint = document.createElement('div')
app.mount(mountPoint)
const unmount = () => app.unmount()
return [result!, unmount]
}
describe('useCounter', () => {
it('should initialize correctly', () => {
const [counter, unmount] = withSetup(() => useCounter({ initialValue: 5 }))
expect(counter.count.value).toBe(5)
unmount()
})
it('should support increment and decrement', () => {
const [counter, unmount] = withSetup(() => useCounter({ min: 0, max: 10 }))
counter.increment()
expect(counter.count.value).toBe(1)
counter.decrement()
expect(counter.count.value).toBe(0)
counter.decrement()
expect(counter.count.value).toBe(0)
unmount()
})
it('should correctly compute isMin and isMax', () => {
const [counter, unmount] = withSetup(() => useCounter({ min: 0, max: 2, initialValue: 0 }))
expect(counter.isMin.value).toBe(true)
counter.increment()
counter.increment()
expect(counter.isMax.value).toBe(true)
unmount()
})
})
describe('useFetch', () => {
it('should handle loading state correctly', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
new Response(JSON.stringify({ id: 1 }), { status: 200 })
)
const [fetchResult, unmount] = withSetup(() =>
useFetch<{ id: number }>('/api/test', { immediate: false })
)
const promise = fetchResult.execute()
expect(fetchResult.isLoading.value).toBe(true)
await promise
expect(fetchResult.isLoading.value).toBe(false)
expect(fetchResult.data.value).toEqual({ id: 1 })
unmount()
})
})
describe('useFormValidation', () => {
it('should detect invalid input', () => {
const [form, unmount] = withSetup(() =>
useFormValidation(
{ email: '' },
{ email: [(v: string) => !!v || 'Required'] }
)
)
form.validate()
expect(form.errors.value.email).toBe('Required')
unmount()
})
})
Anti-Patterns: Patterns You Must Avoid
Anti-Pattern 1: Directly Modifying Props in a Composable
// ❌ Bad: Directly modifying props
export function useBadExample(props: { value: number }) {
const doubled = computed(() => props.value * 2)
const setDoubled = (val: number) => {
props.value = val / 2 // Runtime warning! Props are readonly
}
return { doubled, setDoubled }
}
// ✅ Good: Notify parent via emit
export function useGoodExample(props: { value: number }, emit: (e: 'update:value', val: number) => void) {
const doubled = computed(() => props.value * 2)
const setDoubled = (val: number) => {
emit('update:value', val / 2)
}
return { doubled, setDoubled }
}
Anti-Pattern 2: Registering Global Side Effects Without Cleanup
// ❌ Bad: No cleanup
export function useBadListener() {
window.addEventListener('resize', handleResize) // Still runs after unmount
}
// ✅ Good: Cleanup with onUnmounted
export function useGoodListener() {
const handleResize = () => { /* ... */ }
window.addEventListener('resize', handleResize)
onUnmounted(() => window.removeEventListener('resize', handleResize))
}
Anti-Pattern 3: Returning ref.value Instead of the ref Itself
// ❌ Bad: Returns .value, loses reactivity
export function useBadReturn() {
const count = ref(0)
return { count: count.value } // Not reactive!
}
// ✅ Good: Return the ref itself
export function useGoodReturn() {
const count = ref(0)
return { count } // Keeps reactivity
}
Anti-Pattern 4: Using this in a Composable
// ❌ Bad: No this in Composables
export function useBadThis() {
this.count++ // TypeError: Cannot read property 'count' of undefined
}
// ✅ Good: Use closure variables
export function useGoodClosure() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
5 Common Pitfalls and Solutions
| Pitfall | Symptom | Solution |
|---|---|---|
Calling Composable outside setup |
onUnmounted is not called error |
Ensure calling within setup() or <script setup>, or use withSetup test helper |
| Destructuring losing reactivity | Data updates but view doesn't refresh | Use toRefs() to destructure: const { count } = toRefs(useCounter()) |
| Shared state accidental pollution | Multiple components share the same ref | Create new refs on each Composable call, or use factory pattern |
| Async operations not cancelled | Callbacks still execute after unmount | Use AbortController and cancel in onUnmounted |
| Accessing old DOM in watch | DOM manipulation before nextTick |
Use await nextTick() inside watch callback before DOM operations |
10 Error Troubleshooting Checklist
isRef(x) is false: Check if you accidentally used.valuewhen returning from the Composable — always return the ref itselfCannot read property of nullin onMounted: DOM doesn't exist during SSR — useonMountedinstead of accessing DOM directly insetup- Memory leak: Check if
setInterval,addEventListener,watchare cleaned up inonUnmounted Uncaught TypeError: composable is not a function: Check import path and default/named export matchingonUnmounted is not called: Composable called outside setup context — ensure execution within component setup- Computed not updating: Dependent ref not properly passed, or
shallowRefused causing deep property changes not triggering updates - Watch not triggering: Check if
{ immediate: true }is needed, or if watch source is a getter function - SSR hydration mismatch: Using
window/documentbrowser APIs in Composable — defer withonMountedor checkimport.meta.env.SSR - TypeScript inference failure: Composable return type not explicitly declared — use
interface UseXxxReturnto define return types - Circular dependency: Composable A calls B, B calls A — refactor to one-way dependency or extract shared logic to a third Composable
Vue 3 Composable vs React Hooks Comparison
| Dimension | Vue 3 Composable | React Hooks |
|---|---|---|
| Execution timing | Once in setup | Every render |
| Dependency declaration | Auto-tracked, no declaration | Manual deps array |
| Closure trap | None (setup runs once) | Yes (stale closure problem) |
| Conditional calling | ✅ Allowed | ❌ Not allowed (Rules of Hooks) |
| Return values | ref/reactive (reactive) | Plain values (need useState) |
| Lifecycle | onMounted/onUnmounted | useEffect cleanup function |
| Performance optimization | computed auto-cache | useMemo manual cache |
| Learning curve | Medium (reactivity system) | High (closures + deps + rules) |
| Type safety | ✅ Native support | ✅ Native support |
| Ecosystem tools | VueUse / Pinia | ahooks / SWR |
Key difference: Vue 3 Composables don't suffer from React Hooks' closure trap and dependency array problems — this is a natural advantage of Vue's reactivity system.
Recommended Tools
These online tools can significantly boost your productivity when developing Composables:
- JSON Formatter — Format JSON data returned by Composables, debug reactive state
- Base64 Encoder — Encode API request parameters, handle auth tokens in useFetch
- Hash Calculator — Compute data hashes, generate cache keys and detect changes
Summary: Vue 3 Composables are the ultimate solution for frontend logic reuse in 2026. Master these 7 design patterns (state management, async data, form validation, event bus, debounce, pagination, infinite scroll), combine with Vitest testing and anti-pattern avoidance, and you'll build maintainable, testable, reusable Vue 3 applications like a senior developer. Remember: A good Composable is a pure logic unit — it doesn't care about UI, doesn't care about components, it only cares about the composition of state and behavior.
Try these browser-local tools — no sign-up required →