Vue.js: Custom Composables
Last updated: 2026-10-20
Composable functions (also called hooks) are the core reuse pattern of Vue 3's Composition API—they package "reactive state + business logic" into reusable functions. In essence, a composable is a "logic bundle" assembled from ref / computed / watch and friends.
Mastering composables is the key to maintainable Vue applications—they keep component code lean and let business logic be shared across components. This lesson takes you from zero to your own composable library.
1. What You'll Learn
- What a composable (hook) is and why you need one
- The
useXxxnaming convention - 5 hands-on composables (useMouse / useLocalStorage / useFetch / useDebounce / useToggle)
- Best practices for parameters and return values
- Wrapping lifecycle hooks
- Sharing composables across components (the composables/ directory)
- The VueUse library (200+ ready-made composables)
2. The Shopping-Cart "Fetch + Debounce" Logic Copied 5 Times
(1) The Pain: 5 Search Boxes, 5 Copies of the Same Code
Alice's admin has 5 search inputs (orders/products/users, etc.), each with fetch + debounce logic:
// ❌ Broken version: 5 components, 5 copies of the same code
// OrdersSearch.vue
let timer = null
const searchQuery = ref('')
const results = ref([])
watch(searchQuery, (val) => {
if (timer) clearTimeout(timer)
timer = setTimeout(async () => {
const res = await fetch(`/api/orders?q=${val}`)
results.value = await res.json()
}, 300)
})
// ProductsSearch.vue - identical logic (only the URL differs)
// UsersSearch.vue - identical logic (only the URL differs)
// ... 2 more ...
Total: 50 lines × 5 = 250 lines of duplicated code. Fixing a bug means editing 5 places.
Product manager Charlie adds a new requirement:
"Alice, change the debounce from 300ms to 500ms. And add a minimum length check (only search with 3+ characters)."
Alice has to edit 5 files. Error-prone.
(2) The Vue Composable Fix: One useSearch, Reused 5 Times
// composables/useSearch.js - a single composable
const { ref, watch } = Vue
export function useSearch(apiUrl, options = {}) {
const { debounceMs = 300, minLength = 0 } = options
const searchQuery = ref('')
const results = ref([])
const loading = ref(false)
let timer = null
watch(searchQuery, (val) => {
if (timer) clearTimeout(timer)
if (val.length < minLength) {
results.value = []
return
}
timer = setTimeout(async () => {
loading.value = true
const res = await fetch(`${apiUrl}?q=${val}`)
results.value = await res.json()
loading.value = false
}, debounceMs)
})
return { searchQuery, results, loading }
}
<!-- OrdersSearch.vue - a one-line call -->
<script setup>
import { useSearch } from '@/composables/useSearch'
const { searchQuery, results, loading } = useSearch('/api/orders', { debounceMs: 500, minLength: 3 })
</script>
<!-- ProductsSearch.vue - the same one line (different URL) -->
<script setup>
import { useSearch } from '@/composables/useSearch'
const { searchQuery, results, loading } = useSearch('/api/products', { debounceMs: 500, minLength: 3 })
</script>
One useSearch function → reused in 5 places. Change the debounce once and it applies everywhere.
(3) The Payoff
After adopting composables:
- Lines of code: 250 → 60 (-76%)
- One place to change: a single composable file
- Adding a search box: a one-line call
- Testable: useSearch can be unit tested on its own
3. Composable Basics
(1) Naming Convention
// ✅ Correct: useXxx naming
useMouse()
useLocalStorage('key')
useFetch('/api/users')
useDebounce(searchQuery, 500)
useToggle(false)
// ❌ Wrong: other naming
fetchUser() // doesn't start with use
mouseTracker() // doesn't start with use
getStorage() // get/set verbs don't count as hooks
(2) 5 Characteristics
| Characteristic | Description |
|---|---|
| Starts with use | Industry standard (React uses useXxx too) |
| Returns reactive data | Returns ref / computed / reactive |
| Accepts parameters | Usually primitives (string/number/object) |
| Usable standalone | useXxx() inside a component is enough |
| Composable | Multiple composables can call each other |
(3) File Organization
src/
├── components/ # components
├── views/ # pages
├── composables/ # composable functions (the focus)
│ ├── useMouse.js
│ ├── useLocalStorage.js
│ ├── useFetch.js
│ ├── useDebounce.js
│ ├── useToggle.js
│ └── index.js # single barrel export
├── stores/ # Pinia stores
└── App.vue
▶ Example: Write a useCounter and reuse it in two independent components
▶ Try it YourselfOutput:
Two green-bordered cards side by side, "Counter A" and "Counter B", both starting at "10 (double 20)".
Click A's "+1" three times → A shows "13 (double 26)" while B stays at "10 (double 20)" — each useCounter call creates a fresh, isolated state.
Click A's "Reset" → it returns to 10 and B is completely unaffected.
4. 5 Hands-On Composables
(1) useMouse: Track the Mouse Position
// composables/useMouse.js
const { ref, onMounted, onUnmounted } = Vue
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
<!-- MouseTracker.vue -->
<script setup>
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>Mouse: {{ x }}, {{ y }}</p>
</template>
(2) useLocalStorage: Reactive localStorage
// composables/useLocalStorage.js
const { ref, watch } = Vue
export function useLocalStorage(key, defaultValue) {
const stored = localStorage.getItem(key)
const data = ref(stored !== null ? JSON.parse(stored) : defaultValue)
watch(data, (val) => {
localStorage.setItem(key, JSON.stringify(val))
}, { deep: true })
return data
}
<!-- ThemeToggle.vue -->
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'
const theme = useLocalStorage('theme', 'light')
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
Current: {{ theme }}
</button>
</template>
(3) useFetch: Generic Data Fetching
// composables/useFetch.js
const { ref } = Vue
export function useFetch(url) {
const data = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchData() {
loading.value = true
error.value = null
try {
const res = await fetch(url.value || url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
// Fetch immediately
fetchData()
return { data, loading, error, refetch: fetchData }
}
<!-- UserList.vue -->
<script setup>
import { useFetch } from '@/composables/useFetch'
const { data: users, loading, error, refetch } = useFetch('/api/users')
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="refetch">Refresh</button>
</template>
(4) useDebounce: Debounce a Value
// composables/useDebounce.js
const { customRef } = Vue
export function useDebounce(value, delay = 300) {
let timer = null
// Keep independent internal state instead of proxying the original ref
let state = value
return customRef((track, trigger) => ({
get() {
track()
return state
},
set(newValue) {
clearTimeout(timer)
state = newValue
timer = setTimeout(() => {
trigger()
}, delay)
}
}))
}
// Usage
const searchInput = ref('')
const debouncedSearch = useDebounce(searchInput, 500)
watch(debouncedSearch, (val) => {
console.log('Search:', val)
// Only runs after 500ms
})
(5) useToggle: Flip a Boolean
// composables/useToggle.js
const { ref } = Vue
export function useToggle(initialValue = false) {
const value = ref(initialValue)
function toggle() {
value.value = !value.value
}
function setTrue() {
value.value = true
}
function setFalse() {
value.value = false
}
return { value, toggle, setTrue, setFalse }
}
<!-- ModalToggle.vue -->
<script setup>
import { useToggle } from '@/composables/useToggle'
const { value: showModal, setTrue, setFalse } = useToggle(false)
</script>
<template>
<button @click="setTrue">Open Modal</button>
<Modal v-if="showModal" @close="setFalse" />
</template>
▶ Example: useMouse + useLocalStorage + useToggle in one demo
▶ Expand runnable example (67 lines)
Output:
Three bordered panels render. Panel 1 reads Current: ON with a Toggle button;
hiding it also hides panel 2.
Panel 2 shows X: 0, Y: 0 and tracks the pointer as you move the mouse.
Panel 3 shows Current username: (empty), an input, and Save / Clear buttons—saving writes the name to localStorage so it survives a page refresh.
1. useToggle switch
Current: ON
[ Toggle ]
2. useMouse pointer position (move the mouse)
X: 320, Y: 180
3. useLocalStorage persistence (survives refresh)
Current username: (empty)
[ Enter a name ] [ Save ] [ Clear ]
# after typing "Alice" and clicking Save:
Current username: Alice ← still Alice after F5
5. Composable Best Practices
(1) 6 Parameter Designs
// 1. Simple parameters
useDebounce(value, 300)
// 2. An options object
useFetch(url, { method: 'POST', body: data })
// 3. Reference types (reactive)
useFetch(ref('/api/users'))
// 4. Function parameters (callback)
useEventListener('click', (e) => console.log(e))
// 5. Multiple parameters
useLocalStorage('key', defaultValue, { mergeDefaults: true })
// 6. Generics (TypeScript)
useLocalStorage<User>('user', { name: '', age: 0 })
(2) 5 Return-Value Designs
// 1. Return a ref directly
export function useCounter() {
const count = ref(0)
return { count }
}
// 2. Return a ref + methods
export function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
// 3. Return a namespace (recommended)
export function useCounter() {
const count = ref(0)
return {
state: { count },
actions: { increment: () => count.value++ }
}
}
// 4. Return a ref (destructuring friendly)
export function useCounter() {
return ref(0) // return the ref directly; consumers use .value
}
// 5. Return readonly (prevent outside mutation)
const { readonly } = Vue
export function useCounter() {
const count = ref(0)
return { count: readonly(count) }
}
(3) 6 Lifecycle Wrapping Patterns
// 1. onMounted + onUnmounted (most common)
export function useEventListener(event, handler) {
onMounted(() => window.addEventListener(event, handler))
onUnmounted(() => window.removeEventListener(event, handler))
}
// 2. Automatic watch cleanup
export function useWatch(source, callback) {
const stop = watch(source, callback)
onUnmounted(() => stop())
}
// 3. Automatic setInterval cleanup
export function useInterval(fn, delay) {
let timer = null
onMounted(() => { timer = setInterval(fn, delay) })
onUnmounted(() => clearInterval(timer))
}
// 4. Automatic setTimeout cleanup
export function useTimeout(fn, delay) {
let timer = null
onMounted(() => { timer = setTimeout(fn, delay) })
onUnmounted(() => clearTimeout(timer))
}
// 5. Cancelling async tasks
export function useAsyncTask(task) {
let cancelled = false
onUnmounted(() => { cancelled = true })
return async () => {
if (cancelled) return
await task()
}
}
// 6. Cleanup on route leave
import { onBeforeRouteLeave } from 'vue-router'
export function useRouteLeave(callback) {
onBeforeRouteLeave((to, from) => {
if (callback()) return false // block navigation
})
}
6. The VueUse Library (200+ Composables)
(1) What Is VueUse?
VueUse is the Vue community's composable utility library. It offers 200+ ready-made composables (useMouse / useLocalStorage / useDebounce / useEventListener and many more), saving you the trouble of writing them yourself.
# Install
npm install @vueuse/core
(2) 5 Common VueUse Composables
import { useMouse, useLocalStorage, useDebounceFn, useEventListener, useToggle } from '@vueuse/core'
// 1. Mouse position
const { x, y } = useMouse()
// 2. Reactive localStorage
const theme = useLocalStorage('theme', 'light')
// 3. Function debouncing
const debouncedFn = useDebounceFn(() => {
console.log('Debounced!')
}, 500)
// 4. Global event listener
useEventListener('resize', () => {
console.log('Window resized')
})
// 5. Boolean toggling
const { value, toggle } = useToggle()
(3) 5 Common Use Cases
| Scenario | VueUse composable |
|---|---|
| Mouse position | useMouse / useMouseInElement |
| Scroll position | useScroll / useInfiniteScroll |
| localStorage | useLocalStorage / useStorage |
| Network status | useOnline / useNetwork |
| Media queries | useMediaQuery / useBreakpoints |
| Fullscreen | useFullscreen |
| Clipboard | useClipboard |
| Mouse dragging | useDraggable |
| Element size | useElementSize / useResizeObserver |
| Debounce and throttle | useDebounceFn / useThrottleFn |
7. Putting It Together: Debounced Search with useFetch + useDebounce
▶ Example: useFetch + useDebounce (debounced search)
▶ Try it YourselfOutput:
A heading Search (500ms debounce), an input, and two lines render.
Typing vue fires 3 watcher updates, so Actual trigger count climbs to 3, while Debounced search term stays (empty) until 500ms after the last keystroke, then becomes the typed text.
Search (500ms debounce)
[ Enter a search term... ]
Actual trigger count: 3
Debounced search term: (empty) ← while still typing "vue"
Debounced search term: vue ← 500ms after the last keystroke
(1) 📊 5 Common Mistakes at a Glance
| Mistake | Symptom | Fix |
|---|---|---|
| Not starting with use | Team doesn't recognize it | Name it useXxx |
| Returning a plain object | Loses reactivity | Return a ref |
| Not cleaning up side effects | Memory leak | Clean up in onUnmounted |
| Over-abstracting | Composables used for simple cases | Keep simple logic in the component |
| 5 levels of nested composables | Hard to debug | Split into small components or use Pinia |
(2) 📊 Composables vs Other Patterns
| Pattern | Reusability | Performance | Maintainability | Best for |
|---|---|---|---|---|
| Copy-paste | ❌ | ⭐⭐⭐ | ❌ | One-off |
| Mixin (Vue 2) | ⭐⭐ | ⭐⭐ | ⭐⭐ | Legacy projects |
| Composable | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Recommended |
| Pinia | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Large applications |
| Event Bus | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | Cross-component communication |
❓ FAQ
<script setup>.useSearch can use useFetch + useDebounce internally.const { data } = useFetch('/api') and assert on data.value. Use Vitest for unit tests.📖 Summary
- A composable (hook) is a reusable function built from ref/computed/watch
- 5 characteristics: starts with use / returns reactive data / takes parameters / usable standalone / composable
- 5 hands-on examples: useMouse / useLocalStorage / useFetch / useDebounce / useToggle
- 6 parameter designs + 5 return-value designs
- 6 lifecycle wrapping patterns (onMounted/onUnmounted)
- VueUse provides 200+ composables
- Composable vs Pinia: composables for small logic, Pinia for large state
📝 Exercises
-
Basic (difficulty ⭐) Implement a useToggle function:
- Takes an initial value (default false)
- Returns { value, toggle, setTrue, setFalse }
- Try all 4 usages inside a component
-
Intermediate (difficulty ⭐⭐) Implement the full useFetch:
- Supports a ref parameter (reactive URL)
- Supports a refetch method
- Re-fetches when watched dependencies change
- Error handling + loading state
- Test it in 2 components
-
Challenge (difficulty ⭐⭐⭐) Build a complete "e-commerce admin composable library":
- 5 composables: useCart / useAuth / useSearch / usePagination / useTable
- Each composable is at least 50 lines, fully tested
- Export everything from
composables/index.js - Write 5 test cases (with Vitest)
- Compare your implementation with the VueUse equivalent
- Document the API of every composable