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

  1. isRef(x) is false: Check if you accidentally used .value when returning from the Composable — always return the ref itself
  2. Cannot read property of null in onMounted: DOM doesn't exist during SSR — use onMounted instead of accessing DOM directly in setup
  3. Memory leak: Check if setInterval, addEventListener, watch are cleaned up in onUnmounted
  4. Uncaught TypeError: composable is not a function: Check import path and default/named export matching
  5. onUnmounted is not called: Composable called outside setup context — ensure execution within component setup
  6. Computed not updating: Dependent ref not properly passed, or shallowRef used causing deep property changes not triggering updates
  7. Watch not triggering: Check if { immediate: true } is needed, or if watch source is a getter function
  8. SSR hydration mismatch: Using window/document browser APIs in Composable — defer with onMounted or check import.meta.env.SSR
  9. TypeScript inference failure: Composable return type not explicitly declared — use interface UseXxxReturn to define return types
  10. 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.


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 →

#Vue3 Composable#组合式函数#逻辑复用#设计模式#2026