Appearance
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
useprefix (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
- Examples:
- 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
- Examples:
- 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,useAuthPermissionsinstead of genericuseSession).- Don't shadow framework-provided composable names such as
useRoute,useRouter,useSlots, oruseId.
- Don't shadow framework-provided composable names such as
- 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>andComputedRef<T>for reactivity. - Avoid inline return object types unless trivially small.
- Input arguments should use a typed
Argsobject 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,
@paramand@returnsare optional; rely on types unless extra clarity is needed. - For JavaScript,
@paramand@returnsare mandatory. - Document every returned property/method inside the
UseXxxResulttype.
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(see05.Queries-Mutations). Raw API helpers come fromsrc/services/api/<entity>.ts. Composables consume either, but must not redefine queries.
Lifecycle Hooks
- Lifecycle helpers such as
onMounted/onUnmountedare 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.,
onMountedwithonUnmounted) 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/refreshfunctions or returnisLoading,error, anddatarefs 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/refreshshape 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
reffirst, - then
computed, - and finally methods/helpers.
- reactive
- Nested composable instances may follow
computedvalues 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
useprefix (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. See03.Componentsfor 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 members | Return anonymous objects with inline types for non-trivial composables |
| Keep composables import-safe; start async work inside exposed functions or lifecycle hooks | Trigger API calls, subscriptions, or mutations at module scope |
| Focus each composable on a single responsibility | Mix unrelated concerns (e.g., authentication and UI state) in one composable |
Export composables with the use prefix | Export without the use prefix |
Keep module composables in a single-level composables/ folder | Create 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.