Vue Composables: Extracting Logic Without Making a Mess

Composables are Vue's way of sharing stateful logic between components — when to extract one, what a good one looks like, and the patterns that turn a clean abstraction into everyone's headache.

Vue Composables: Extracting Logic Without Making a Mess

Every Vue codebase eventually hits the same wall: three components doing the same thing slightly differently. A debounce here, a fetch with loading state there, form focus handling duplicated because it was "just a few lines". Composables exist for exactly this — but they're easy to misuse, and a bad composable is worse than duplicated code. Here's how we keep ours useful.

What to extract, what to leave alone

The trigger isn't "code got long" — it's overlap. When two components share real logic (state, a pattern of calls, a lifecycle concern), that's a composable. A component that's merely long probably isn't; splitting it into arbitrary pieces produces indirection without reuse.

Good candidates from real projects:

  • Stateful API calls — useResource() wrappers that own data, isLoading, and error so three list pages stop hand-rolling the same loading spinner
  • Browser APIs — clipboard, geolocation, visibility, media queries; anything with lifecycle you don't want to re-glue each time
  • Timer and event patterns — debounce, polling, resize handling with proper cleanup

What we deliberately keep as plain functions: string formatting, date math, validation rules. If there's no state and no lifecycle, don't pay for a composable.

What a good one looks like

export function useClipboard(timeout = 2000) {
  const copied = ref(false)
  let timer: ReturnType<typeof setTimeout> | undefined

  async function copy(text: string) {
    await navigator.clipboard.writeText(text)
    copied.value = true
    clearTimeout(timer)
    timer = setTimeout(() => (copied.value = false), timeout)
  }

  onUnmounted(() => clearTimeout(timer))

  return { copied, copy }
}

Three things worth noticing. It returns refs, not raw values, so the component can watch and react. The timeout cleans up after itself with onUnmounted, so a component unmount can't leave a dangling timer updating dead state. And every option has a sensible default, so the call site is one line.

The failure mode to watch: composables that reach into shared singletons or a module-level store without making it obvious. When useCart() silently couples every caller to global state, refactoring later means tracing that coupling first.

Naming and returns beat magic

Call composables useSomething and return an object with named properties — { data, isLoading, error }, not an array someone has to destructure by memory. Array returns (Vue's useFetch style) read well for two items and become a guessing game past that.

One habit that pays off: don't return reactive objects built from refs. Spread refs out instead — { count, increment } with count a ref keeps reactivity intact, while returning a plain object of values snapshots the state and quietly breaks every watch downstream.

Watch the boundaries

Call composables inside setup() — that's the only place onUnmounted and inject actually bind. Composables that wrap a Pinia store or an injected service are fine, but say so in the name and docstring (useCurrentWorkspace vs useWorkspaceStore), because the two get confused constantly once a team grows.

The test that matters

We judge a composable the same way every time: could a new component use it without reading its source? If the answer is yes — named returns, defaults, documented side effects — it's pulling its weight. If every use requires opening the file, the abstraction has inverted: it's now a dependency to manage rather than a tool to pick up. Composables earn their keep by being forgettable to use, and that's the standard we hold them to.