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 owndata,isLoading, anderrorso 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.