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


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:

JS
// ❌ 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

JS
// 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 }
}
VUE
<!-- 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:


3. Composable Basics

(1) Naming Convention

JS
// ✅ 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

TEXT
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 Yourself

Output:

TEXT
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

JS
// 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 }
}
VUE
<!-- 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

JS
// 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
}
VUE
<!-- 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

JS
// 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 }
}
VUE
<!-- 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

JS
// 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)
    }
  }))
}
JS
// 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

JS
// 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 }
}
VUE
<!-- 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)
▶ Try it Yourself

Output:

TEXT
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.
TEXT
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

JS
// 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

JS
// 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

JS
// 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.

BASH
# Install
npm install @vueuse/core

(2) 5 Common VueUse Composables

JS
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

▶ Try it Yourself

Output:

TEXT
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.
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

Q What's the difference between a composable and a mixin?
A A mixin is the Vue 2 reuse mechanism (implicit merging, name collisions). A composable is the Vue 3 way (explicit return values, type-friendly). Vue 3 recommends composables.
Q Must a composable be called at the top level of setup?
A Yes. Composables use ref/watch/lifecycle hooks, so they must be called at the top level of <script setup>.
Q Can a composable receive props?
A Not directly. But it can receive a ref (reactive) or a plain value. The VueUse library uses ref parameters extensively.
Q Can composables call each other?
A Yes. A composable is just a function, so nesting works. Example: useSearch can use useFetch + useDebounce internally.
Q When should I write my own vs use VueUse?
A Write your own for business-specific logic (useSearch / useCart). Use VueUse for generic logic (mouse/scroll/debounce/fullscreen) and save 80% of the time.
Q Is a composable a copy of React hooks?
A Yes, it's inspired by React Hooks. Vue 3 borrowed and improved on the idea: it's built on refs (automatic dependency tracking) and called at the top level of setup (no useEffect wrapper).
Q How do I test a composable?
A A composable is a plain JS function—just call it: const { data } = useFetch('/api') and assert on data.value. Use Vitest for unit tests.

📖 Summary


📝 Exercises

  1. Basic (difficulty ⭐) Implement a useToggle function:

    • Takes an initial value (default false)
    • Returns { value, toggle, setTrue, setFalse }
    • Try all 4 usages inside a component
  2. 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
  3. Challenge (difficulty ⭐⭐⭐) Build a complete "e-commerce admin composable library":

    1. 5 composables: useCart / useAuth / useSearch / usePagination / useTable
    2. Each composable is at least 50 lines, fully tested
    3. Export everything from composables/index.js
    4. Write 5 test cases (with Vitest)
    5. Compare your implementation with the VueUse equivalent
    6. Document the API of every composable
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏