When the Composition API landed in Vue 3, the conversation focused on the syntax. setup() versus the Options API. Refs versus data. Computed properties that look almost the same but live somewhere else. That conversation missed the bigger change.

The real shift wasn't how you write a component. It was where state lives — and once you stop seeing it as a syntax topic, the way you structure Vue applications starts to look different. This article is about that change: what the Composition API actually unlocks, where it earns its keep, and the small set of mistakes that make it worse than the Options API ever was.

The Old Unit Of Reuse Was The Component. The New One Is The Composable

In Options API days, the only place you could put logic was inside a component. If two components needed the same logic, your tools were:

  • Mixins (everyone agreed they were a mistake by 2019).
  • Renderless components (clever, awkward to use).
  • Plain helpers (which couldn't touch reactive state).

The Composition API gives you a fourth option that quietly replaced the other three: composables. Plain functions that can call ref, computed, watch, lifecycle hooks — anything reactive — and return values the component uses.

TypeScript src/features/orders/composables/useOrderFilters.ts
import { computed, ref } from 'vue'

export function useOrderFilters() {
  const search = ref('')
  const status = ref<'all' | 'paid' | 'failed'>('all')

  const query = computed(() => ({
    q: search.value.trim(),
    status: status.value === 'all' ? undefined : status.value,
  }))

  return { search, status, query }
}

That's a composable. A plain function. The convention is to prefix it with use (like a React hook) so the linter and humans both know it can call other reactive primitives.

Inside a component:

Vue
<script setup lang="ts">
import { useOrderFilters } from '@/features/orders/composables/useOrderFilters'

const { search, status, query } = useOrderFilters()
</script>

<template>
  <input v-model="search" />
  <select v-model="status">…</select>
  <OrderList :query="query" />
</template>

The component gets reactive values back. The logic — what fields exist, what defaults look like, how the query is shaped — lives in one place that anybody can find by name.

What This Actually Changes In Practice

Three things shift once composables are the unit of reuse:

State stops being component-shaped. A piece of logic that was previously trapped in a component (because it needed data() or computed) can now live next to its data, its types, and its tests. The "where do I put this?" question gets a clear answer: in a composable, in the same feature folder.

The boundary between UI and behaviour gets sharper. A well-named composable is a contract: this is what it owns, this is what it returns. The component becomes a presentational shell that wires the contract into a template. When you want to change behaviour, you change one file. When you want to change the look, you change another.

Tests get shorter. A composable is a pure-ish function. You call it inside a test (with @vue/test-utils or by wrapping it in a thin component), assert on the returned refs and computed values, and you're done. No mounting required for behavioural tests.

TypeScript
import { describe, it, expect } from 'vitest'
import { useOrderFilters } from './useOrderFilters'

describe('useOrderFilters', () => {
  it('omits status when "all"', () => {
    const { status, query } = useOrderFilters()
    status.value = 'all'
    expect(query.value.status).toBeUndefined()
  })
})

A before-and-after architecture diagram. Left: a component with all logic inside its setup, hard to share. Right: the same logic extracted into a useOrderFilters composable in a feature folder, with the component reduced to a template binding.
Composables move logic out of components without losing reactivity.

Where Composables Earn Their Keep

Four shapes consistently pay rent. Outside of these, ask twice before reaching for one.

Subscription patterns. Anything with subscribe / unsubscribe (event listeners, WebSockets, intervals) is dramatically cleaner as a composable. Setup, teardown, edge cases — written once, reused everywhere.

TypeScript
import { onMounted, onUnmounted, ref } from 'vue'

export function useOnlineStatus() {
  const online = ref(navigator.onLine)

  const update = () => { online.value = navigator.onLine }

  onMounted(() => {
    window.addEventListener('online', update)
    window.addEventListener('offline', update)
  })
  onUnmounted(() => {
    window.removeEventListener('online', update)
    window.removeEventListener('offline', update)
  })

  return online
}

Workflows that combine state + transitions. A modal lifecycle, a multi-step form, an undoable action. These benefit from naming the transitions and shipping them together with the state they read.

Wrapping imperative third-party APIs. Charts, maps, video players. The composable becomes the seam: the component renders a <div ref="container" />, the composable owns the imperative dance.

Sharing genuinely repeated logic. useDebounce, useLocalStorage, useMediaQuery, useFocusTrap. These are the small utility composables every team eventually has, and they're worth pulling into a shared folder once a third caller appears.

Where Composables Become Hidden Complexity

The same flexibility that makes composables useful makes them easy to misuse. The patterns that consistently bite:

The composable that fetches. A useUser() that hits the network, holds loading state, and updates on subscriptions starts as a convenience and ends as a tiny framework. Two components calling it cause two requests. Three composables calling each other become a four-step waterfall hidden inside one render. For server data, reach for VueUse's useFetch, TanStack Query (@tanstack/vue-query), or Pinia Colada — they handle the cache, deduplication, and refetch policy that you'd otherwise reinvent.

The composable chain.

TypeScript
function useUserProfile() {
  const user = useUser()                       // request 1
  const settings = useUserSettings(user)       // request 2 (waits for 1)
  const avatar = useAvatar(settings)           // request 3 (waits for 2)
}

Each is innocent. Together, they're a serial network waterfall hidden inside one function. The fix is to make the dependencies visible at the call site, or to flatten the requests into a single endpoint.

The composable that returns 14 things. When the return shape outgrows about five values, you usually have two composables pretending to be one. Splitting them makes both easier to read, test, and reuse.

The shared ref nightmare. A composable that uses ref(null) inside the function body gives every caller their own ref. If two components both call useGlobalToast(), they don't share the toast — they each get their own. For genuinely shared state, hoist the ref above the function (module-level) or use Pinia.

TypeScript
// shared module-scope ref — every caller sees the same toast
const visibleToast = ref<Toast | null>(null)

export function useGlobalToast() {
  return {
    visibleToast,
    show(message: string) { visibleToast.value = { message, id: Date.now() } },
  }
}

A Habit That Keeps This Healthy

Two questions worth asking before extracting a composable:

  1. Does it have a single, namable purpose? "Sync with localStorage" — yes. "Manage the dashboard" — no.
  2. Does it hide a side effect that should be visible at the call site? Network calls, global subscriptions, timers — these often deserve to be on the surface, not behind a friendly name.

If the answers are "yes" and "no", extract it. Otherwise leave the logic in the component until a real second use case appears.

What Composition API Doesn't Solve

Things composables don't make easier:

  • Cross-feature coordination. Two features that need to talk still need a shared place — Pinia, an event bus, or a parent that owns both.
  • Server-driven state. As above — reach for a query library.
  • Architectural decisions. Where does the auth check live? Which layer owns navigation? What's the seam with the backend? The Composition API is a code organisation tool, not a system design tool.

The One-Sentence Summary

The Composition API moved Vue from "components share through inheritance and mixins" to "components share through plain functions". That sounds small. In a real codebase, it changes which file you open, where state lives, what reuse looks like, and how new engineers onboard. Used carefully — single-purpose composables, visible side effects, server data through a real library — it makes Vue applications easier to grow than any framework I've worked with. Used carelessly, it makes every component into a private framework with rules nobody else knows. Pick the version that ages well.