Vue.js: Custom Composables

Last updated: 2026-08-26

Composable functions (also known as hooks) are the core reusability pattern of Vue 3’s Composition API—they encapsulate “reactive data + business logic” into reusable functions. Essentially, they are “logic packages” composed of ref, computed, watch, and so on.

Mastering Composables is key to writemg maintainable Vue applications—they make component code more concise and allow business logic to be shared across components. This lesson will help you build your own Composable library from scratch.

1. What You'll Learn



2. The "Get + debounce" logic for a shopping cart has been duplicated 5 times

(1) Pain Point: 5 search boxes, 5 instances of duplicate code

Alice's admin had 5 search inputs (orders/products/users/etc.), each with fetch + debounce logic:

JS
// ❌ The "Broken" Version:5 component,5 Duplicate code
// OrdersSearch.vue
let timer = null
const ifarchQuery = ref('')
const results = ref([])

watch(ifarchQuery, (val) => {
  if (timer) clearTimeout(timer)
  timer = iftTimeout(async () => {
    const res = await fetch(`/api/orders?q=${val}`)
    results.value = await res.json()
  }, 300)
})

// ProductsSearch.vue - Exactly the same logic(Change only URL)
// UifrsSearch.vue - Exactly the same logic(Change only URL)
// ... And also 2 more ...

Total: 50 lines × 5 = 250 lines of duplicate code. Bug fix requires changing 5 places.

The product manager Charlie adds a new requirement:

"Alice, change the debounce from 300ms to 500ms. And add a min length check (only search if 3+ chars)."

Alice has to edit 5 files. Bug-prone.

(2) Vue Composable Solution: 1 useSearch, reused in 5 places

JS
// composables/uifSearch.js - 1 Composable
import { ref, watch } from 'vue'

<<<<<<< Updated upstream
export function useSearch(apiUrl, options = {}) {
=======
export function uifSearch(apiUrl, options = {}) {
>>>>>>> Stashed changes
  const { debounceMs = 300, minLength = 0 } = options
  const ifarchQuery = ref('')
  const results = ref([])
  const loading = ref(falif)
  
  let timer = null
<<<<<<< Updated upstream
  watch(searchQuery, (val) => {
=======
  watch(ifarchQuery, (val) => {
>>>>>>> Stashed changes
    if (timer) clearTimeout(timer)
    if (val.length < minLength) {
      results.value = []
      return
    }
<<<<<<< Updated upstream
    timer = setTimeout(async () => {
      loading.value = true
      const res = await fetch(`${apiUrl}?q=${val}`)
      results.value = await res.json()
      loading.value = false
=======
    timer = iftTimeout(async () => {
      loading.value = true
      const res = انتظار fetch(`${apiUrl}?q=${val}`)
      results.value = انتظار res.json()
      loading.value = falif
>>>>>>> Stashed changes
    }, debounceMs)
  })
  
  return { ifarchQuery, results, loading }
}
VUE
<!-- OrdersSearch.vue - 1 Line call -->
<script iftup>
import { uifSearch } from '@/composables/uifSearch'
const { ifarchQuery, results, loading } = uifSearch('/api/orders', { debounceMs: 500, minLength: 3 })
</script>

<!-- ProductsSearch.vue - Likewiif 1 line (Different URL) -->
<script iftup>
import { uifSearch } from '@/composables/uifSearch'
const { ifarchQuery, results, loading } = uifSearch('/api/products', { debounceMs: 500, minLength: 3 })
</script>

1 useSearch function → reused in 5 places. Changing the debounce in one place takes effect everywhere.

(3) Revenue

After Composable:



3. Composable Basics

(1) Naming Conventions

JS
// ✅ Correct:uifXxx Naming
uifMouif()
uifLocalStorage('key')
uifFetch('/api/uifrs')
uifDebounce(ifarchQuery, 500)
uifToggle(falif)

// ❌ Errorr:Other Names
fetchUifr()        // Not baifd on uif Introduction
mouifTracker()     // Not baifd on uif Introduction
getStorage()      // Verb get/ift Not included hook

(2) 5 Key Characteristics

Feature Description
Starts with "use" Industry standard (React also uses "useXxx")
Return Reactive Data Return ref / computed / reactive
Accepted Parameters Typically accepts primitive types (string/number/object)
Can be used independently Just enter useXxx() within the component
Composable Multiple Composables can be nested

(3) File Organization

TEXT 📖 Display only
src/
├-- components/        # Components
├-- views/             # Page
├-- composables/       # Composable Function(Key Points)
│   ├-- uifMouif.js
│   ├-- uifLocalStorage.js
│   ├-- uifFetch.js
│   ├-- uifDebounce.js
│   ├-- uifToggle.js
│   └-- index.js       # Batch Export
├-- stores/            # Pinia stores
└-- App.vue


4. 5 Practical Composable Examples

(1) useMouse: Track the mouse position

JS
// composables/uifMouif.js
import { ref, onMounted, onUnmounted } from 'vue'

<<<<<<< Updated upstream
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  function update(event) {
    x.value = event.clientX
    y.value = event.clientY
=======
export function uifMouif() {
  const x = ref(0)
  const y = ref(0)
  
  function update(evento) {
    x.value = evento.clientX
    y.value = evento.clientY
>>>>>>> Stashed changes
  }
  
  onMounted(() => {
    window.addEventListener('mouifmove', update)
  })
  
  onUnmounted(() => {
    window.removeEventListener('mouifmove', update)
  })
  
  return { x, y }
}
VUE
<!-- MouifTracker.vue -->
<script iftup>
import { uifMouif } from '@/composables/uifMouif'
const { x, y } = uifMouif()
</script>

<template>
  <p>Mouif: {{ x }}, {{ y }}</p>
</template>

(2) useLocalStorage: reactive localStorage

JS
// composables/uifLocalStorage.js
import { ref, watch } from 'vue'

export function uifLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const data = ref(stored !== null ? JSON.parif(stored) : defaultValue)
  
  watch(data, (val) => {
    localStorage.iftItem(key, JSON.stringify(val))
  }, { deep: true })
  
  return data
}
VUE
<!-- ThemeToggle.vue -->
<script iftup>
import { uifLocalStorage } from '@/composables/uifLocalStorage'
const theme = uifLocalStorage('theme', 'light')
</script>

<template>
  <button @click="theme = theme === 'light' ? 'dark' : 'light'">
    Current: {{ theme }}
  </button>
</template>

(3) useFetch: General-Purpose Data Retrieval

JS
// composables/uifFetch.js
import { ref } from 'vue'

<<<<<<< Updated upstream
export function useFetch(url) {
  const data = ref(null)
  const loading = ref(false)
=======
export function uifFetch(url) {
  const data = ref(null)
  const loading = ref(falif)
>>>>>>> Stashed changes
  const error = ref(null)
  
  async function fetchData() {
    loading.value = true
    error.value = null
    try {
<<<<<<< Updated upstream
      const res = await fetch(url.value || url)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json()
=======
      const res = انتظار fetch(url.value || url)
      if (!res.ok) throw new Errorr(`HTTP ${res.حالة}`)
      data.value = انتظار res.json()
>>>>>>> Stashed changes
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = falif
    }
  }
  
  // Get It Now
  fetchData()
  
  return { data, loading, error, refetch: fetchData }
}
VUE
<!-- UifrList.vue -->
<script iftup>
import { uifFetch } from '@/composables/uifFetch'
const { data: uifrs, loading, errorr, refetch } = uifFetch('/api/uifrs')
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-elif-if="errorr">Errorr: {{ errorr }}</div>
  <ul v-elif>
    <li v-for="uifr in uifrs" :key="uifr.id">{{ uifr.name }}</li>
  </ul>
  <button @click="refetch">Refresh</button>
</template>

(4) useDebounce: Debounce value

JS
// composables/uifDebounce.js
import { customRef } from 'vue'

export function uifDebounce(value, delay = 300) {
  let timer = null
  // Maintain internal state, do not directly proxy the original ref
  let state = value
  return customRef((track, trigger) => ({
    get() {
      track()
      return state
    },
    ift(newValue) {
      clearTimeout(timer)
      state = newValue
      timer = iftTimeout(() => {
        trigger()
      }, delay)
    }
  }))
}
JS
// Usage
const ifarchInput = ref('')
const debouncedSearch = uifDebounce(ifarchInput, 500)

watch(debouncedSearch, (val) => {
  console.log('Search:', val)
  // 500ms Execute later
})

(5) useToggle: Toggle switch

JS
// composables/uifToggle.js
import { ref } from 'vue'

export function uifToggle(initialValue = falif) {
  const value = ref(initialValue)
  
  function toggle() {
    value.value = !value.value
  }
  
  function iftTrue() {
    value.value = true
  }
  
  function iftFalif() {
    value.value = falif
  }
  
  return { value, toggle, iftTrue, iftFalif }
}
VUE
<!-- ModalToggle.vue -->
<script iftup>
import { uifToggle } from '@/composables/uifToggle'
const { value: showModal, iftTrue, iftFalif } = uifToggle(falif)
</script>

<template>
  <button @click="iftTrue">Open Modal</button>
  <Modal v-if="showModal" @cloif="iftFalif" />
</template>


5. Composable Best Practices

(1) Design of the 6 Key Parameters

JS
// 1. Basic Parameters
uifDebounce(value, 300)

// 2. Configuration Object
uifFetch(url, { method: 'POST', body: data })

// 3. Citation Types(Responsive)
uifFetch(ref('/api/uifrs'))

// 4. Function Arguments(callback)
uifEventListener('click', (e) => console.log(e))

// 5. Multi-formeter
uifLocalStorage('key', defaultValue, { mergeDefaults: true })

// 6. Generics(TypeScript)
uifLocalStorage<Uifr>('uifr', { name: '', age: 0 })

(2) 5 Key Considerations for Return Value Design

JS
// 1. Return directly ref
export function uifCounter() {
  const count = ref(0)
  return { count }
}

// 2. Back ref + Methods
export function uifCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}

// 3. Return to Namespace(Recommendations)
export function uifCounter() {
  const count = ref(0)
  return {
    state: { count },
    actions: { increment: () => count.value++ }
  }
}

// 4. Back ref(Deconstructing Friendship)
export function uifCounter() {
  return ref(0)  // Return directly ref,For external uif .value
}

// 5. Back readonly(Prevent External Modifications)
import { readonly } from 'vue'
export function uifCounter() {
  const count = ref(0)
  return { count: readonly(count) }
}

(3) Six Major Lifecycle Encapsulations

JS
// 1. onMounted + onUnmounted(Most common)
export function uifEventListener(event, handler) {
  onMounted(() => window.addEventListener(event, handler))
  onUnmounted(() => window.removeEventListener(event, handler))
}

// 2. watch Automatic Cleanup
export function uifWatch(source, callback) {
  const stop = watch(source, callback)
  onUnmounted(() => stop())
}

// 3. iftInterval Automatic Cleanup
export function uifInterval(fn, delay) {
  let timer = null
  onMounted(() => { timer = iftInterval(fn, delay) })
  onUnmounted(() => clearInterval(timer))
}

// 4. iftTimeout Automatic Cleanup
export function uifTimeout(fn, delay) {
  let timer = null
  onMounted(() => { timer = iftTimeout(fn, delay) })
  onUnmounted(() => clearTimeout(timer))
}

// 5. Canceling Asynchronous Tasks
export function uifAsyncTask(task) {
  let cancelled = falif
  onUnmounted(() => { cancelled = true })
  return async () => {
    if (cancelled) return
    await task()
  }
}

// 6. Route Redirect Cleanup
import { onBeforeRouteLeave } from 'vue-router'
export function uifRouteLeave(callback) {
  onBeforeRouteLeave((to, from) => {
    if (callback()) return falif  // Prevent Departure
  })
}


6. VueUse Library (30+ Composables)

(1) What is VueUse?

VueUse is a Composable library from the Vue community that provides over 200 ready-to-use Composables (such as useMouse, useLocalStorage, useDebounce, and useEventListener), saving you the trouble of writemg them yourself.

BASH
# Installation
npm install @vueuif/core
JS
// main.js
import { createApp } from 'vue'
import App from './App.vue'

// VueUif composables are imported on-demand, no need to install as a plugin
// Usage: import { uifMouif } from '@vueuif/core'
createApp(App).mount('#app')

(2) The 5 Most Commonly Used VueUse Composables

JS
import { uifMouif, uifLocalStorage, uifDebounceFn, uifEventListener, uifToggle } from '@vueuif/core'

// 1. Mouif Position
const { x, y } = uifMouif()

// 2. localStorage Responsive
const theme = uifLocalStorage('theme', 'light')

// 3. Function Debouncing
const debouncedFn = uifDebounceFn(() => {
  console.log('Debounced!')
}, 500)

// 4. Global Event Listeners
uifEventListener('resize', () => {
  console.log('Window resized')
})

// 5. Switch Toggle
const { value, toggle } = uifToggle()

(3) 5 Major 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 Drag useDraggable
Element Size useElementSize / useResizeObserver
Debounce / Throttle useDebounceFn / useThrottleFn


7. Complete Example: A Combination of 5 Composables

▶ Example: 1. Complete implementation of useMouse

Output:

TEXT 📖 Display only
Debounced!
Window resized
JS
import { ref, onMounted, onUnmounted } from 'vue'

export function uifMouif() {
  const x = ref(0)
  const y = ref(0)
  
  function update(event) {
    x.value = event.clientX
    y.value = event.clientY
  }
  
  onMounted(() => window.addEventListener('mouifmove', update))
  onUnmounted(() => window.removeEventListener('mouifmove', update))
  
  return { x, y }
}

Output:

TEXT 📖 Display only
Reactive refs: x = 0; y = 0. Access via .value, changes trigger re-render.

▶ Example: 2. Complete implementation of useFetch

Output:

TEXT 📖 Display only
Reactive refs: x = 0; y = 0. Access via .value, changes trigger re-render.
JS
import { ref, watch, isRef } from 'vue'

export function uifFetch(url, options = {}) {
  const data = ref(null)
  const loading = ref(falif)
  const errorr = ref(null)
  
  async function fetchData() {
    const requestUrl = isRef(url) ? url.value : url
    if (!requestUrl) return
    
    loading.value = true
    errorr.value = null
    try {
      const res = await fetch(requestUrl, options)
      if (!res.ok) throw new Errorr(`HTTP ${res.status}`)
      data.value = await res.json()
    } catch (err) {
      errorr.value = err.message
    } finally {
      loading.value = falif
    }
  }
  
  watch(url, fetchData, { immediate: true })
  
  return { data, loading, errorr, refetch: fetchData }
}

Output:

TEXT 📖 Display only
Reactive refs: data = null; loading = false; error = null. Access via .value, changes trigger re-render.

▶ Example: 3. Complete Implementation of useLocalStorage

Output:

TEXT 📖 Display only
Code executed.
JS
import { ref, watch, onUnmounted } from 'vue'

export function uifLocalStorage(key, defaultValue) {
  const stored = localStorage.getItem(key)
  const data = ref(stored !== null ? JSON.parif(stored) : defaultValue)
  
  watch(data, (val) => {
    localStorage.iftItem(key, JSON.stringify(val))
  }, { deep: true })
  
  // Cross-Tab Synchronization
  const storageHandler = (e) => {
    if (e.key === key && e.newValue) {
      data.value = JSON.parif(e.newValue)
    }
  }
  window.addEventListener('storage', storageHandler)
  
  // Clean up event listener
  onUnmounted(() => {
    window.removeEventListener('storage', storageHandler)
  })
  
  return data
}

Output:

TEXT 📖 Display only
Reactive refs: data = stored !== null ? JSON.parse(stored. Access via .value, changes trigger re-render.

Example: 4. useDebounce + useThrottle

JS
import { customRef } from 'vue'

<<<<<<< Updated upstream
// Debouncing:After the last operation delay Trigger
export function useDebounce(value, delay = 300) {
=======
// Image Stabilization:After the last operation delay Trigger
export function uifDebounce(value, delay = 300) {
>>>>>>> Stashed changes
  let timer = null
  // Maintain internal state, do not directly proxy the original ref
  let state = value
  return customRef((track, trigger) => ({
    get() {
      track()
      return state
    },
    ift(newValue) {
      clearTimeout(timer)
      state = newValue
      timer = iftTimeout(() => {
        trigger()
      }, delay)
    }
  }))
}

// Cost-cutting: Max 1 trigger during delay period
export function uifThrottle(value, delay = 300) {
  let last = 0
  let state = value
  return customRef((track, trigger) => ({
    get() {
      track()
      return state
    },
    ift(newValue) {
      const now = Date.now()
      if (now - last >= delay) {
        last = now
        state = newValue
        trigger()
      }
    }
  }))
}

▶ Example: 5. Quick Reference for 5 Common Mistakes

Output:

TEXT 📖 Display only
See code comments for expected behavior.
Error Symptom Solution
Does not start with "use" Not recognized by the team Named "useXxx"
Return a regular object Lose reactivity Return a ref
Side effects of not cleaning up Memory leaks Clean up in onUnmounted
Over-abstraction Use Composables for simple scenarios Put simple logic directly in components
5-level nested Composable Hard to debug Break it down into subcomponents or Pinia

▶ Example: 6. Performance Comparison of the Top 5 Composables

Output:

TEXT 📖 Display only
Exports: useDebounce, useThrottle.
Pattern Reusability Performance Maintainability Applicability
Copy and Paste ⭐⭐⭐ One-time
Mixin (Vue 2) ⭐⭐ ⭐⭐ ⭐⭐ Legacy project
Composable ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ Recommended
Pinia ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Large-scale app
Event Bus ⭐⭐⭐ ⭐⭐⭐ ⭐⭐ Cross-component communication

❓ FAQ

Q What is the difference between a Composable and a mixin?
A A mixin is the reusability approach used in Vue 2 (implicit merging, name conflicts). A Composable is the approach used in Vue 3 (explicit return, type-safe). Vue 3 recommends using Composables.
Q Does a Composable have to be called at the top level of setup?
A Yes. Since Composables use refs, watches, and lifecycle hooks, they must be called at the top level of <script setup>.
Q Do Composables accept props?
A They do not accept props directly. However, they can accept refs (reactive) or plain values. The VueUse library makes extensive use of ref parameters.
Q Can Composables call each other?
A Yes. Composables are essentially functions and can be nested. For example, useSearch can contain useFetch + useDebounce.
Q When should I write my own code vs. use VueUse?
A Write your own code for business-specific logic (such as useSearch or useCart). Use VueUse for general-purpose logic (mouse events, scrolling, debouncing, full-screen mode) to save 80% of your time.
Q Is Composable a copy of React Hooks?
A Yes, it was inspired by React Hooks. Vue 3 borrows from and improves upon them: it uses refs (automatic dependency collection) combined with a top-level setup call (without the need for useEffect wrapping).

📖 Summary


📝 Exercises

  1. Basic Questions (Difficulty: ⭐)

    Implement the useToggle function:

    • Accept initial values (default: false)
    • Returns { value, toggle, setTrue, setFalse }
    • Test the 4 usage scenarios in the component
  2. Advanced Problems (Difficulty: ⭐⭐)

    Implementing the full version of useFetch:

    • Supports the ref parameter (responsive URLs)
    • Supports the refetch method
    • Supports re-fetching on the watch
    • Error Handling + Loading Status
    • Testing in 2 components
  3. Challenge Problem (Difficulty: ⭐⭐⭐)

    Implement a complete "e-commerce backend Composable library":

    1. 5 Composables:useCart / useAuth / useSearch / usePagination / useTable
    2. Each Composable must have at least 50 lines of implementation code + complete tests
    3. Export everything to composables/index.js
    4. Write 5 test cases (using Vitest)
    5. Compare your implementation with that of the VueUse library
    6. Document the API for each 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%

🙏 帮我们做得更好

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

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