Updated June 2026. Tested on Vue 3.4+. Part of the Techalyst Vue series.
A composable is not a framework feature. It is just a function whose name starts with use, that leans on Vue's reactivity, and that you pull a piece of logic into so a component does not have to carry it. The mechanics take five minutes to learn. What takes longer is writing them so they stay flexible and do not turn into a tangle six months later.
So rather than list techniques, it helps to walk through the decisions you actually make every time you write one: what it takes in, what it hands back, where it lives, and how it deals with shared state and async work. Each decision has a pattern that tends to pay off.
Decision one: what it accepts
The single most useful habit is letting a composable accept either a ref or a plain value. A caller should be able to pass a static string and another caller should be able to pass a reactive ref, and your composable should not care.
toValue() makes this painless. It unwraps a ref, calls a getter, or returns a plain value as is, so you read the current value the same way regardless of what was handed in:
import { toValue, computed } from 'vue'
export function useFormattedPrice(amount) {
return computed(() => {
const value = toValue(amount) // works for a ref, a getter, or a number
return new Intl.NumberFormat('en-LK', {
style: 'currency',
currency: 'LKR',
}).format(value)
})
}
useFormattedPrice(2500) // plain number
useFormattedPrice(priceRef) // a ref, stays reactive
useFormattedPrice(() => p.value) // a getter
In TypeScript the type for this is MaybeRefOrGetter<T>. The point is the same in plain JavaScript: read arguments through toValue and your composable stops being fussy about how it is called.
Decision two: how it is configured
One or two arguments are fine as positional arguments. Past that, switch to a single options object with sensible defaults. It reads better at the call site and you can add a new option later without breaking every caller.
export function useFetch(url, { immediate = true, headers = {} } = {}) {
// ...
}
useFetch('/api/users')
useFetch('/api/users', { immediate: false })
The = {} at the end matters. Without it, calling useFetch(url) with no second argument throws when you try to destructure.
Decision three: what it returns
Return the smallest surface that is actually useful, and nothing more. If a composable produces one thing, return that one thing so the caller can name it freely:
const price = useFormattedPrice(amount)
If it produces several related things, return an object so the caller destructures what they need:
const { data, error, loading } = useFetch('/api/users')
What you should not do is return your internal scratch state, half-finished refs, or helpers nobody outside the composable needs. The return value is the public contract. Keep it tight.
Decision four: keep the logic out of the reactivity
A composable that mixes the actual calculation with refs and watchers is hard to test and hard to reuse outside a component. Pull the real work into a plain function, then wrap a thin reactive layer over it.
// Pure logic. No Vue. Trivial to unit test.
export function cartTotals(items) {
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0)
return { subtotal, tax: subtotal * 0.15 }
}
// Thin reactive wrapper.
import { computed, toValue } from 'vue'
export function useCartTotals(items) {
return computed(() => cartTotals(toValue(items)))
}
Now the arithmetic can be tested on its own and reused on the server, while the composable just makes it reactive. The thinner the reactive layer, the better.
Decision five: where it lives
Not every composable needs its own file. If a chunk of logic is only used by one component but is cluttering the setup, you can define the composable in the same file, above the component, and call it inside. It still gives you the grouping and the clean naming without a premature extraction.
Move it to its own module the moment a second component needs it. Until then, an inline composable is a perfectly good way to tidy a single component.
Decision six: shared versus per-caller state
By default every call to a composable gets its own fresh state. That is usually what you want. But sometimes you want the opposite: one piece of state that every caller shares, like the logged-in user.
You get that by declaring the state outside the function, at module scope, so it is created once and the composable just hands back the same instance:
import { ref, readonly } from 'vue'
const user = ref(null) // created once, shared by everyone
export function useAuth() {
function setUser(u) { user.value = u }
function logout() { user.value = null }
return { user: readonly(user), setUser, logout }
}
Notice that the state goes out as readonly, with named functions to change it. A caller can read user anywhere but cannot reassign it from some random component, so every change is traceable to setUser or logout. That control matters most exactly when state is shared.
This is a tiny store without any library. It is enough for a handful of shared values. Once the shared state grows real structure, actions, and cross-cutting concerns, that is the signal to reach for Pinia instead of hand-rolling more of these.
Decision seven: async without going async
It is tempting to make a data-loading composable async and await it. Resist that. If the composable itself returns a promise, the template cannot bind to its result reactively, and every caller has to await.
Instead, return refs straight away and fill them in when the work finishes:
import { ref } from 'vue'
export function useUsers() {
const data = ref(null)
const error = ref(null)
const loading = ref(true)
fetch('/api/users')
.then(r => r.json())
.then(json => { data.value = json })
.catch(e => { error.value = e })
.finally(() => { loading.value = false })
return { data, error, loading }
}
The component gets data, error and loading immediately and the template reacts as they change. No awaiting, no blocking. If a caller genuinely needs to wait, you can also return the promise alongside the refs, but the refs are what the template wants.
Decision eight: one job per composable
A composable should do one thing. When a use function starts juggling several unrelated responsibilities, that is the same smell as a component doing too much, and the fix is the same: split it. Smaller composables are easier to name, test and reuse, and nothing stops one from calling another:
export function useCheckout(cartItems) {
const { subtotal, tax } = useCartTotals(cartItems)
const { submit, submitting } = useOrderSubmission()
return { subtotal, tax, submit, submitting }
}
useCheckout does not reimplement totals or submission. It composes the two composables that already handle those, and hands the component a tidy summary of what it needs. Composables built out of smaller composables stay readable as the feature grows, which is the whole point of pulling logic out in the first place.
Wrapping up
Composables are easy to write and easy to write badly. The patterns that keep them clean all come back to a few decisions. Accept refs or values through toValue. Switch to an options object once you have more than a couple of settings. Return the smallest useful surface. Keep the real logic in a plain function. Inline it until a second component needs it. Declare state outside the function only when you truly want it shared, and hand it out as readonly. Return reactive refs from async work instead of making the whole thing async. And keep each composable to one job, building bigger ones out of smaller ones. Get those calls right and your use functions stay a pleasure to reach for.
All comments ()
No comments yet
Be the first to leave a comment on this post.