Advanced Vue 3 Composables: Reusable Patterns and Interview Questions 2026

Master advanced Vue 3 composables with reusable patterns, Composition API techniques, and interview questions. Covers reactive state extraction, async composables, provide/inject, and testing strategies.

Advanced Vue 3 composables showing reusable patterns and Composition API architecture

Vue 3 composables have become the standard mechanism for extracting and reusing reactive logic across components. With Vue 3.6 introducing Alien Signals and Vapor Mode compatibility, mastering composable patterns directly impacts application architecture, performance, and maintainability in 2026.

What Is a Vue 3 Composable?

A composable is a function that leverages the Composition API to encapsulate and reuse stateful logic. Unlike mixins, composables provide explicit inputs and outputs, full TypeScript inference, and zero naming collisions. The convention is to prefix composable names with use (e.g., useCounter, useFetch).

Anatomy of a Well-Structured Composable

A production-grade composable follows a predictable structure: accept configuration via arguments, create reactive state internally, expose a typed return object. This pattern ensures composability, testability, and clear API boundaries.

useCounter.tstypescript
import { ref, computed, type Ref } from 'vue'

interface UseCounterOptions {
  min?: number
  max?: number
  initialValue?: number
}

interface UseCounterReturn {
  count: Ref<number>
  doubled: Ref<number>
  increment: () => void
  decrement: () => void
  reset: () => void
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { min = 0, max = Infinity, initialValue = 0 } = options

  const count = ref(initialValue)
  const doubled = computed(() => count.value * 2)

  function increment() {
    if (count.value < max) count.value++
  }

  function decrement() {
    if (count.value > min) count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return { count, doubled, increment, decrement, reset }
}

The explicit return type UseCounterReturn serves two purposes: it documents the composable's public API, and it prevents accidental exposure of internal implementation details. Consuming components destructure exactly what they need, keeping template bindings transparent.

Async Composables with Error Handling and Loading States

Data fetching remains one of the most common use cases for composables. A robust async composable manages loading state, error handling, and automatic cleanup — patterns that interviewers frequently probe.

useFetchData.tstypescript
import { ref, watchEffect, onUnmounted, toValue, type Ref, type MaybeRefOrGetter } from 'vue'

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

export function useFetchData<T>(
  url: MaybeRefOrGetter<string>
): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const isLoading = ref(false)
  let abortController: AbortController | null = null

  async function fetchData() {
    // Cancel any in-flight request
    abortController?.abort()
    abortController = new AbortController()

    isLoading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url), {
        signal: abortController.signal
      })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      isLoading.value = false
    }
  }

  // Re-fetch when URL changes reactively
  watchEffect(() => {
    fetchData()
  })

  onUnmounted(() => abortController?.abort())

  return { data, error, isLoading, refresh: fetchData }
}

Several details matter here. The MaybeRefOrGetter<string> parameter type accepts plain strings, refs, or getters — maximizing flexibility for callers. The AbortController prevents race conditions when the URL changes rapidly. Cleanup via onUnmounted avoids memory leaks and state updates on destroyed components.

Lifecycle Hooks Inside Composables

Composables that call onMounted, onUnmounted, or other lifecycle hooks must be invoked synchronously within setup(). Calling a composable inside an async callback or a setTimeout will silently fail to register the lifecycle hook because there is no active component instance.

Composable Composition: Building Higher-Level Abstractions

The true power of composables emerges when they compose other composables. This mirrors the functional composition principle from the Vue documentation — small, focused units combined into complex behavior without inheritance hierarchies.

usePaginatedSearch.tstypescript
import { ref, computed, watch, type Ref } from 'vue'
import { useFetchData } from './useFetchData'
import { useDebouncedRef } from './useDebouncedRef'

interface UsePaginatedSearchReturn<T> {
  query: Ref<string>
  page: Ref<number>
  results: Ref<T[] | null>
  totalPages: Ref<number>
  isLoading: Ref<boolean>
  error: Ref<string | null>
  nextPage: () => void
  prevPage: () => void
}

export function usePaginatedSearch<T>(
  baseUrl: string,
  perPage = 20
): UsePaginatedSearchReturn<T> {
  const query = useDebouncedRef('', 300)
  const page = ref(1)
  const totalPages = ref(1)

  const apiUrl = computed(
    () => `${baseUrl}?q=${encodeURIComponent(query.value)}&page=${page.value}&limit=${perPage}`
  )

  const { data, error, isLoading } = useFetchData<{ items: T[]; total: number }>(apiUrl)

  const results = computed(() => data.value?.items ?? null)

  watch(data, (response) => {
    if (response) {
      totalPages.value = Math.ceil(response.total / perPage)
    }
  })

  // Reset to page 1 when query changes
  watch(query, () => { page.value = 1 })

  function nextPage() {
    if (page.value < totalPages.value) page.value++
  }

  function prevPage() {
    if (page.value > 1) page.value--
  }

  return { query, page, results, totalPages, isLoading, error, nextPage, prevPage }
}

This composable combines useFetchData for HTTP logic and useDebouncedRef for input throttling, without either knowing about the other. Each layer remains independently testable. The computed URL automatically triggers re-fetches when query or page changes, with debouncing preventing excessive API calls.

Ready to ace your Vue.js / Nuxt.js interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Dependency Injection with provide/inject for Composable Architecture

Prop drilling breaks down in deeply nested component trees. Vue's provide/inject system enables composable state to be shared across an entire subtree without passing props through intermediary components.

useTheme.tstypescript
import { provide, inject, ref, readonly, type InjectionKey, type Ref } from 'vue'

type Theme = 'light' | 'dark' | 'system'

interface ThemeContext {
  theme: Readonly<Ref<Theme>>
  setTheme: (t: Theme) => void
  resolvedTheme: Readonly<Ref<'light' | 'dark'>>
}

const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')

export function provideTheme(initial: Theme = 'system') {
  const theme = ref<Theme>(initial)

  const resolvedTheme = computed<'light' | 'dark'>(() => {
    if (theme.value !== 'system') return theme.value
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
  })

  function setTheme(t: Theme) {
    theme.value = t
  }

  const context: ThemeContext = {
    theme: readonly(theme),
    setTheme,
    resolvedTheme: readonly(resolvedTheme)
  }

  provide(ThemeKey, context)
  return context
}

export function useTheme(): ThemeContext {
  const context = inject(ThemeKey)
  if (!context) {
    throw new Error('useTheme() requires a parent component to call provideTheme()')
  }
  return context
}

The InjectionKey<ThemeContext> symbol ensures type safety — TypeScript infers the correct type when inject resolves the value. Wrapping refs with readonly() prevents consumers from mutating state directly, enforcing unidirectional data flow through the explicit setTheme function.

This provider/consumer pattern scales well for authentication context, feature flags, locale settings, and any cross-cutting concern that multiple components need to access.

Reusable Form Validation Composable

Form handling is a common source of duplication. A generic validation composable extracts the repetitive parts while remaining agnostic about specific validation rules.

useFormValidation.tstypescript
import { reactive, computed, type UnwrapNestedRefs } from 'vue'

type ValidationRule<T> = (value: T) => string | true
type FieldRules<T> = { [K in keyof T]?: ValidationRule<T[K]>[] }

interface UseFormReturn<T extends Record<string, any>> {
  fields: UnwrapNestedRefs<T>
  errors: Record<keyof T, string>
  isValid: Ref<boolean>
  validate: () => boolean
  resetErrors: () => void
}

export function useFormValidation<T extends Record<string, any>>(
  initialValues: T,
  rules: FieldRules<T>
): UseFormReturn<T> {
  const fields = reactive({ ...initialValues }) as UnwrapNestedRefs<T>

  const errors = reactive(
    Object.keys(initialValues).reduce(
      (acc, key) => ({ ...acc, [key]: '' }),
      {} as Record<keyof T, string>
    )
  )

  function validate(): boolean {
    let valid = true
    for (const key of Object.keys(rules) as (keyof T)[]) {
      const fieldRules = rules[key] || []
      errors[key] = '' as any
      for (const rule of fieldRules) {
        const result = rule(fields[key])
        if (result !== true) {
          errors[key] = result as any
          valid = false
          break // Stop at first error per field
        }
      }
    }
    return valid
  }

  function resetErrors() {
    for (const key of Object.keys(errors)) {
      (errors as any)[key] = ''
    }
  }

  const isValid = computed(() =>
    Object.values(errors).every((e) => e === '')
  )

  return { fields, errors, isValid, validate, resetErrors }
}

Consumers pass an initial values object and a rules map. Each rule function returns true on success or an error message string on failure. This composable integrates naturally with Vue's v-model through the reactive fields object.

Testing Composables in Isolation

A key advantage of composables over mixins is testability. Since composables are plain functions returning reactive state, most tests need nothing more than a minimal wrapper to provide the setup() context.

useCounter.spec.tstypescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useCounter } from './useCounter'

function withSetup<T>(composable: () => T): { result: T; unmount: () => void } {
  let result!: T
  const wrapper = mount(
    defineComponent({
      setup() {
        result = composable()
        return () => h('div')
      }
    })
  )
  return { result, unmount: () => wrapper.unmount() }
}

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = withSetup(() => useCounter())
    expect(result.count.value).toBe(0)
  })

  it('respects min and max boundaries', () => {
    const { result } = withSetup(() =>
      useCounter({ min: 0, max: 3, initialValue: 3 })
    )
    result.increment()
    expect(result.count.value).toBe(3) // Capped at max

    result.count.value = 0
    result.decrement()
    expect(result.count.value).toBe(0) // Capped at min
  })

  it('computes doubled value reactively', () => {
    const { result } = withSetup(() => useCounter({ initialValue: 5 }))
    expect(result.doubled.value).toBe(10)
    result.increment()
    expect(result.doubled.value).toBe(12)
  })
})

The withSetup helper creates a throwaway component that runs the composable within a valid lifecycle context. This approach works for any composable that needs onMounted, onUnmounted, or other lifecycle hooks. For simple composables without lifecycle dependencies, calling the function directly without a wrapper is sufficient.

VueUse as Reference Architecture

VueUse contains over 200 production composables covering browser APIs, sensors, animations, and utilities. Studying its source code reveals consistent patterns: options objects for configuration, SSR safety guards, and tryOnScopeDispose for cleanup. It serves as an excellent reference for composable architecture decisions.

Common Interview Questions on Vue 3 Composables

Technical interviews in 2026 treat composable mastery as a core Vue competency. The following questions appear frequently and test depth beyond surface-level syntax.

How do composables differ from mixins, and why were mixins deprecated? Mixins suffer from three structural problems: implicit property merging causes naming collisions, the source of data and methods becomes opaque in components using multiple mixins, and TypeScript cannot infer types through mixin composition. Composables solve all three by returning explicit objects, making data flow traceable, and providing full type inference through standard function signatures.

When should a composable use shallowRef instead of ref? Use shallowRef when the ref holds a large object or array that gets replaced entirely rather than mutated. Since shallowRef only tracks reassignment (not deep property changes), it avoids the overhead of deep reactive proxying. Vue 3.6 defaults to shallow reactivity in certain contexts for this performance reason.

What happens if a composable calls onMounted inside an async function? The lifecycle hook registration silently fails. Vue associates lifecycle hooks with the currently active component instance, which is set during synchronous setup() execution. By the time an async callback runs, the active instance may be null or a different component. The solution: call lifecycle hooks synchronously at the top level of the composable, and use watchEffect or watch for async logic.

How do composables interact with Vue's Vapor Mode in 3.6? Vapor Mode compiles templates to direct DOM operations, bypassing the virtual DOM. Composables work identically in Vapor Mode because they operate at the reactivity layer, not the rendering layer. The reactive refs and computed properties from composables trigger fine-grained DOM updates more efficiently under Vapor Mode, making well-structured composables a performance advantage.

Practice more Vue composables interview questions to strengthen these concepts with targeted exercises.

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Conclusion

  • Extract reactive logic into composables whenever two or more components share the same state pattern — prioritize the use prefix convention and typed return objects
  • Handle async operations with AbortController cleanup and MaybeRefOrGetter parameters to maximize composable flexibility
  • Use provide/inject with InjectionKey for type-safe dependency injection in deeply nested component trees, avoiding prop drilling
  • Compose multiple composables into higher-level abstractions rather than building monolithic functions — keep each composable focused on a single concern
  • Test composables in isolation using a withSetup wrapper for lifecycle-dependent composables, or call directly for pure reactive logic
  • Apply readonly() to exposed refs when consumers should not mutate state directly, enforcing unidirectional data flow through explicit setter functions
  • Prepare for Vue 3.6 Vapor Mode by keeping composables at the reactivity layer — they gain automatic performance improvements without code changes

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Tags

#vue
#composables
#composition-api
#interview
#deep-dive

Share

Related articles