Skip to content

Composables

Composables are reusable logic units built with the Vue Composition API. They help extract state, side effects, and domain logic out of components, promoting reusability and separation of concerns (SoC).

Principles

  • Start every composable with the use prefix (useAuth, useCounter, useProductsQuery).
  • Each composable must focus on a single responsibility; don't mix unrelated concerns.
  • Expose a stable public surface: reactive state (ref, computed) and functions that encapsulate behavior.
  • Avoid side effects on import; composables must be safe to import and only perform logic when called.

File Structure

  • Shared composables (used across multiple modules) live in: src/composables/
    • Examples: src/composables/useToggle.ts, src/composables/useMediaQuery.ts, src/composables/useAuth.ts
  • Module-specific composables live alongside their feature: src/pages/<Module>/composables/ (module folder in PascalCase, matching the naming guide)
    • Examples: src/pages/Checkout/composables/useCheckoutTotals.tssrc/pages/Auth/composables/useLoginForm.tssrc/pages/UserProfile/composables/useUserAddressForm.ts
  • Do not create deep composables folders for child submodules. Keep it at one level under the module (src/pages/<Module>/composables/…).
    • src/pages/Orders/composables/useOrderFilters.ts
    • src/pages/Orders/submodule/composables/...
    • src/pages/Orders/composables/subsection/...

Naming

  • Composable names must start with use.
  • Use domain-driven names that explain what it manages, not how it's implemented.
  • For domain-specific suites (e.g., Auth), prefix composables to avoid collisions (useAuthSession, useAuthPermissions instead of generic useSession).
    • Don't shadow framework-provided composable names such as useRoute, useRouter, useSlots, or useId.
  • File names mirror composable names in camelCase:
    • src/composables/useCounter.ts
    • src/composables/counter.ts

Typing

  • Return types must be explicitly declared with an interface or type alias (UseXxxResult).
  • Use Ref<T> and ComputedRef<T> for reactivity.
  • Avoid inline return object types unless trivially small.
  • Input arguments should use a typed Args object when the function grows beyond 1–2 params; name the interface <Composable>Args.
  • Provide safe defaults when destructuring the options object so consumers can omit configuration.
  • Avoid adding additional positional parameters once a composable needs configurability—extend the options object instead to remain backwards compatible.
ts
// src/composables/useCounter.ts
import { ref, type Ref } from 'vue'

export interface UseCounterResult {
  /** Current count */
  count: Ref<number>
  /** Increment by 1 */
  inc: () => void
  /** Reset to 0 */
  reset: () => void
}

/**
 * Manage a numeric counter with increment and reset helpers.
 */
export function useCounter(): UseCounterResult {
  const count = ref(0)
  const inc = () => { count.value += 1 }
  const reset = () => { count.value = 0 }
  return { count, inc, reset }
}
ts
export interface UsePollingArgs {
  intervalMs?: number
  immediate?: boolean
}

export function usePolling({ intervalMs = 5000, immediate = false }: UsePollingArgs = {}): UsePollingResult {
  // ...implementation detail
}

Documentation

  • Add a JSDoc block at the top explaining why it exists and what it manages.
  • For TypeScript, @param and @returns are optional; rely on types unless extra clarity is needed.
  • For JavaScript, @param and @returns are mandatory.
  • Document every returned property/method inside the UseXxxResult type.

Implementation Rules

  • Keep composables import-safe: no API calls, subscriptions, or mutations at module scope.
  • Trigger side effects only inside exposed functions or within lifecycle hooks called from the consumer.
  • When wrapping external libraries, normalize their API into Vue-friendly types (Ref, ComputedRef, etc.).
  • Export composables as named exports only—never export default—so imports stay predictable.
  • Keep internal helpers private; declare them within the composable and only return what forms the public surface.
  • When calling service or API helpers, import them from their module files under src/services/api/ (e.g., @/services/api/checkout.ts) to mirror the folder-structure convention.
  • Query and mutation composables live under src/queries/<entity>.query.ts (see 05.Queries-Mutations). Raw API helpers come from src/services/api/<entity>.ts. Composables consume either, but must not redefine queries.

Lifecycle Hooks

  • Lifecycle helpers such as onMounted/onUnmounted are valid inside composables, but only run them within the composable function so they bind to the consumer component's lifecycle.
  • Always pair setup with teardown (e.g., onMounted with onUnmounted) inside the composable to prevent leaks, rather than delegating cleanup to the consumer.
  • Never trigger lifecycle hooks at module import time; always wrap them inside the composable scope.
  • Watchers created inside a composable bind to the consumer component's scope and stop with it. Never create watchers outside the composable function scope. For the watch-vs-watchEffect choice, see 03.Components.
ts
// src/composables/useScrollListener.ts
import { onMounted, onUnmounted } from 'vue'

export function useScrollListener(callback: () => void) {
  onMounted(() => window.addEventListener('scroll', callback))
  onUnmounted(() => window.removeEventListener('scroll', callback))
}

Async Work

  • Make async initialization explicit: expose load/refresh functions or return isLoading, error, and data refs instead of firing requests implicitly.
  • When using the Query/Mutation layer or plain API calls, drive them through the composable instance (e.g., profile.load()), not by destructuring individual refs.
  • The same load/refresh shape applies whether wrapping a plain API call or a query (see Practical Example).
ts
import { ref, type Ref } from 'vue'
import { fetchUserProfile } from '@/services/api/users'

export interface UseUserProfileResult {
  data: Ref<UserProfile | null>
  error: Ref<unknown>
  isLoading: Ref<boolean>
  load: () => Promise<void>
}

export function useUserProfile(userId: string): UseUserProfileResult {
  const data = ref<UserProfile | null>(null)
  const error = ref<unknown>(null)
  const isLoading = ref(false)

  const load = async () => {
    if (isLoading.value) return
    isLoading.value = true
    error.value = null
    try {
      data.value = await fetchUserProfile(userId)
    } catch (err) {
      error.value = err
    } finally {
      isLoading.value = false
    }
  }

  return { data, error, isLoading, load }
}

Error Handling

  • Composables that wrap async work must expose an error ref (Ref<unknown> or domain-specific union) so callers can decide how to render or retry.
  • Throw only on unrecoverable conditions; otherwise surface the error ref and let the consumer compose the experience.

Stable Return Order

  • Keep the returned object API stable: do not reorder, rename, or remove keys without a deliberate versioning/upgrade plan.
  • Follow the return ordering convention:
    • reactive ref first,
    • then computed,
    • and finally methods/helpers.
  • Nested composable instances may follow computed values and precede methods, as long as their return type is explicitly declared (UseXxxResult).
  • Composables may call other composables. Pass dependencies as explicit ref arguments to keep coupling visible.
ts
export interface UseAuthSessionResult {
  user: Ref<User | null>
  token: Ref<string | null>
  permissions: UseAuthPermissionsResult
  refreshSession: () => Promise<void>
  signOut: () => Promise<void>
}

export function useAuthSession(): UseAuthSessionResult {
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)
  const permissions = useAuthPermissions(user)

  async function refreshSession() {
    // ...
  }

  async function signOut() {
    // ...
  }

  return { user, token, permissions, refreshSession, signOut }
}

Avoid Over-Abstraction

  • Only extract a composable when it improves reuse, separation of concerns, or testability; simple one-off logic can stay inside the component that needs it.
  • Favor fewer, well-scoped composables over sprawling fragments that make a feature harder to follow or test.

Boundary with Stores

  • Pinia stores manage global, app-wide state; composables wrap domain/UI behavior and may orchestrate multiple stores without replacing them.
  • Prefer calling API functions or using the Query/Mutation layer for queries and mutations inside composables; stores should not be the default fetch layer.
  • When a composable coordinates a store, keep the store dependency explicit in the returned API and avoid hiding mutations behind implicit side effects.

Using Composables in Components

  • Instantiate composables once per component setup and assign them to a variable named after the composable without the use prefix (const auth = useAuth()).
  • Never destructure the returned object; reference members through the instance (e.g., auth.profile) to preserve reactivity, keep future additions discoverable, and make it obvious which refs/computed values belong to the composable without chasing definitions.
  • When a page component grows complex, extract cohesive logic into one or more composables, but avoid creating excessive composables that fragment the feature—use discretion to keep maintenance sane.
  • For shared reactive state across component trees, prefer composables or stores over raw provide/inject. See 03.Components for provide/inject guidelines.
vue
<script setup lang="ts">
// ✅ Good
// src/pages/Checkout/Checkout.vue

import { computed } from 'vue'
import { useCheckoutTotals } from '@/pages/Checkout/composables/useCheckoutTotals'

const checkoutTotals = useCheckoutTotals('order-123')

const totalLabel = computed(() => checkoutTotals.total.value.toLocaleString())

checkoutTotals.refresh()
</script>
vue
<script setup lang="ts">
// ❌ Bad
import { computed } from 'vue'
import { useCheckoutTotals } from '@/pages/Checkout/composables/useCheckoutTotals'

const { total, refresh } = useCheckoutTotals('order-456')

refresh()
const totalLabel = computed(() => total.value.toLocaleString())
</script>

Practical Example

ts
// src/pages/Checkout/composables/useCheckoutTotals.ts
import { computed, type ComputedRef } from 'vue'
import { useCheckoutTotalsQuery } from '@/queries/checkout.query'

export interface UseCheckoutTotalsResult {
  subtotal: ComputedRef<number>
  tax: ComputedRef<number>
  total: ComputedRef<number>
  isLoading: ComputedRef<boolean>
  refresh: () => void
}

/** Fetch checkout totals via the shared query and expose derived numbers. */
export function useCheckoutTotals(orderId: string): UseCheckoutTotalsResult {
  const query = useCheckoutTotalsQuery(orderId)

  const subtotal = computed(() => query.data.value?.subtotal ?? 0)
  const tax = computed(() => query.data.value?.tax ?? 0)
  const total = computed(() => subtotal.value + tax.value)
  const isLoading = computed(() => query.isLoading.value)

  const refresh = () => {
    query.refetch()
  }

  return { subtotal, tax, total, isLoading, refresh }
}

Do / Don't

Do ✅Don't ❌
Return a named UseXxxResult (or similar) type and expose typed membersReturn anonymous objects with inline types for non-trivial composables
Keep composables import-safe; start async work inside exposed functions or lifecycle hooksTrigger API calls, subscriptions, or mutations at module scope
Focus each composable on a single responsibilityMix unrelated concerns (e.g., authentication and UI state) in one composable
Export composables with the use prefixExport without the use prefix
Keep module composables in a single-level composables/ folderCreate deep nested composable folder hierarchies
Reference composable members through the instance (checkoutTotals.total)Destructure the returned object

Rationale

  • Enforcing use-prefixed, named exports with typed option objects keeps APIs predictable, extendable, and easy to discover.
  • Lifecycle, async, error, and service import rules ensure composables stay import-safe, clean up after themselves, and integrate consistently with the service layer.
  • Stable return ordering plus domain-specific naming prevents breaking changes and collisions when multiple composables operate in the same space.
  • Boundaries with stores and page usage conventions preserve reactivity, clarify ownership, and keep responsibilities separated for easier maintenance.