Composables in Vue.js
Composables are reusable functions in Vue 3 that bundle reactive state and logic using the Composition API. Think of them as utility functions that carry ref, computed, and lifecycle hooks, packaged once and dropped into any component.
Theory
TL;DR
- Analogy: a composable is a utility belt for a component - pack reactive state and functions once, use them anywhere without rewriting.
- Main difference from Options API: it scatters logic across
data,methods, andmounted; composables keep the same logic in one portable function. - Key rule: a composable returns refs. Always. Returning a
reactiveobject breaks destructuring. - Decision rule: same logic in 2+ components = extract to composable. Single component = inline
setup()is fine. - Always call at the top level of
setup()or<script setup>, never insideifblocks or loops.
Quick example
The classic useMouse from Vue docs shows the full pattern in under 15 lines:
// useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX // Vue tracks changes via Proxy
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update)) // required cleanup
return { x, y }
}<template><p>X: {{ x }}, Y: {{ y }}</p></template>
<script setup>
import { useMouse } from './useMouse'
const { x, y } = useMouse()
</script>x and y stay reactive after destructuring because they are ref objects, not plain numbers. The template updates live as you move the mouse.
Key difference from Options API
Options API ties logic to one component by spreading it across data, methods, mounted, and computed. To share that logic you either copy-paste it or use mixins, which merge silently into the component prototype and create name conflicts.
Composables extract logic to plain functions. The source of every value is explicit. Two composables can both return a count ref without any conflict, because you destructure them under different names. Testing is simpler too: call the function, assert on the returned refs, no DOM needed.
When to use
- Same logic in 2+ components (mouse tracking, form validation, counters) - composable beats copy-paste.
- Multiple components need the same state that is local to their own instance - composable over a global store.
- You want to test reactive logic in isolation - composable over a full component test.
- You need tree-shaking of unused logic - composables get dropped by bundlers if unused.
- Logic only used in one place - keep it inline in
setup().
How Vue tracks reactivity in composables
When a composable runs inside setup(), it operates within the component's active effect scope. Any ref or computed created there links to that scope. Vue uses Proxy under the hood to intercept reads and writes, which is how it tracks which effects depend on which data.
Lifecycle hooks registered inside a composable (onMounted, onUnmounted) attach to the calling component automatically. When the component unmounts, Vue tears down the effect scope and runs all cleanup callbacks. That is why forgetting onUnmounted inside a composable causes memory leaks - the listener or timer outlives the component.
Common mistakes
Reassigning the ref variable instead of updating .value:
const { count } = useCounter()
count = 5 // wrong - reassigns the variable, not the ref
count.value = 5 // correctRefs require .value for writes. Direct reassignment breaks the Proxy link and Vue stops tracking changes.
Forgetting cleanup:
// wrong - timer leaks on component destroy
export function useTimer() {
const id = setInterval(() => doSomething(), 1000)
// no clearInterval anywhere
}
// correct
export function useTimer() {
let id
onMounted(() => { id = setInterval(() => doSomething(), 1000) })
onUnmounted(() => clearInterval(id))
}Shared mutable state defined outside the function:
const count = ref(0) // defined once, at module level
export function useCounter() {
return { count } // all callers share this same ref
}Every component calling useCounter() mutates the same count. This is intentional only for a singleton. For isolated state per component, define refs inside the function body.
Calling a composable outside setup():
export default {
methods: {
doSomething() {
const { x } = useMouse() // wrong - no active component instance here
}
}
}No active effect scope means lifecycle hooks inside the composable are ignored and reactivity may not behave as expected. Call composables only at the top level of setup() or <script setup>.
Conditional composable calls:
// wrong
if (featureFlag) {
const { data } = useFetch('/api/data')
}
// correct
const { data } = useFetch('/api/data')
if (featureFlag) { /* use data */ }Composable calls must be unconditional. Vue's effect tracking depends on a stable call order.
Real-world usage
- VueUse (50k+ GitHub stars) ships 200+ production composables:
useMouse,useStorage,useIntersectionObserver,useDebounceFn. - Nuxt 3's
useFetchanduseAsyncDataare composables for SSR-aware data fetching. - Pinia store logic often gets wrapped in composables so components don't access the store directly.
- Vitest test suites call composables directly to test reactive logic without mounting a component.
- Naming convention: always the
useprefix.useMouse,useFetch,useAuth. Without it, it's just a regular function and other developers won't know it carries reactive state.
Follow-up questions
Q: What is the difference between a composable and a mixin?
A: Mixins merge properties into the component prototype automatically. You cannot tell where a property came from, and two mixins can override each other's methods. Composables return plain objects. The source of every value is explicit at the call site, and name conflicts are impossible.
Q: How do composables work with SSR?
A: On the server there is no window or DOM, so avoid browser-only APIs at the top level of a composable. Use onMounted to guard them, since onMounted does not run on the server. For data fetching, use onServerPrefetch or Nuxt's useAsyncData.
Q: Can you call a composable inside another composable?
A: Yes. Vue's reactivity system tracks dependencies across nested calls. The inner refs connect to the outer composable's scope, which connects to the component's scope. This is how VueUse builds complex composables from simpler ones.
Q: (Senior) Why might a composable cause memory leaks in a list rendering 1000 items?
A: Each item runs the composable independently, creating its own listeners or timers. Without proper cleanup in onUnmounted, unmounting items leaves 1000 orphaned listeners. The fix is always onUnmounted cleanup inside the composable, or using a shared composable instance passed as a prop.
Q: How do you type a composable in TypeScript?
A: A return type annotation covers the basics: function useCounter(): { count: Ref<number>; increment: () => void }. For arguments that can be a plain value, a ref, or a computed, use Vue's MaybeRef<T> type and toValue() to normalize the input.
Examples
Basic: useCounter
A counter with increment, decrement, and reset. Covers the base pattern: create refs inside the function, expose via return.
// useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initialValue }
return { count, increment, decrement, reset }
}<template>
<p>Count: {{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</template>
<script setup>
import { useCounter } from './useCounter'
const { count, increment, decrement, reset } = useCounter(10)
</script>Two components using useCounter() each get their own isolated count. There is no shared state unless you move the ref outside the function.
Intermediate: useValidator for login forms
A login form that validates email and password, exposing a computed isValid and an errors list. The button stays disabled until both fields pass.
// useValidator.js
import { ref, computed } from 'vue'
export function useValidator(initialEmail = '', initialPass = '') {
const email = ref(initialEmail)
const password = ref(initialPass)
const errors = ref([])
const isValid = computed(() => {
errors.value = []
if (!email.value.includes('@')) errors.value.push('Invalid email')
if (password.value.length < 8) errors.value.push('Password too short')
return errors.value.length === 0
})
return { email, password, errors, isValid }
}<template>
<input v-model="email" placeholder="Email" />
<input v-model="password" type="password" placeholder="Password" />
<ul v-if="errors.length">
<li v-for="error in errors" :key="error">{{ error }}</li>
</ul>
<button :disabled="!isValid">Submit</button>
</template>
<script setup>
import { useValidator } from './useValidator'
const { email, password, errors, isValid } = useValidator()
</script>isValid recalculates every time email or password changes. The button disables automatically. No extra watchers needed.
Advanced: useFetchOnFocus with abort controller
Refetches data when the browser tab regains focus. The tricky part: if the user switches tabs twice fast, the first fetch must abort before the second starts. I ran into this exact race condition in production when building a dashboard that polled an endpoint every time users returned to the tab.
// useFetchOnFocus.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useFetchOnFocus(url) {
const data = ref(null)
const loading = ref(false)
let abortController = null
async function fetchData() {
if (abortController) abortController.abort() // cancel in-flight request
abortController = new AbortController()
loading.value = true
try {
const res = await fetch(url, { signal: abortController.signal })
data.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') console.error(e)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchData()
window.addEventListener('focus', fetchData)
})
onUnmounted(() => {
window.removeEventListener('focus', fetchData)
if (abortController) abortController.abort() // cancel any pending request
})
return { data, loading, refetch: fetchData }
}<template>
<p v-if="loading">Loading...</p>
<pre v-else>{{ data }}</pre>
<button @click="refetch">Refresh</button>
</template>
<script setup>
import { useFetchOnFocus } from './useFetchOnFocus'
const { data, loading, refetch } = useFetchOnFocus('/api/dashboard')
</script>Rapid tab switches abort the old fetch before starting a new one. The data ref only updates from the last completed request. No stale data, no race conditions.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.