Vue Composables You Should Be Writing

Jord
Product Engineer & Founder
Vue's Composition API has been around long enough that most developers are comfortable with ref, computed, and watch. But a lot of codebases still have the same patterns copy-pasted across components instead of extracted into composables.
The thing about composables is that the best ones aren't generic utilities you download from npm. They're small, specific functions that encapsulate a pattern you keep repeating in your particular app. Here are some that I write on almost every project.
useDebounce
You've written this inline a hundred times. Extract it once.
export function useDebounce<T>(value: Ref<T>, delay = 300) { const debounced = ref(value.value) as Ref<T> let timeout: ReturnType<typeof setTimeout> watch(value, (newVal) => { clearTimeout(timeout) timeout = setTimeout(() => { debounced.value = newVal }, delay) }) return debounced }
Use it anywhere you have a search input, a filter, or any reactive value that shouldn't trigger effects on every keystroke:
const search = ref('') const debouncedSearch = useDebounce(search, 300) watch(debouncedSearch, (val) => { fetchResults(val) })
Yes, VueUse has useDebounceFn and refDebounced. But this is 12 lines. You don't need a dependency for 12 lines.
useLocalStorage
Another one that gets reimplemented constantly. This version syncs a ref with localStorage and handles the serialisation for you:
export function useLocalStorage<T>(key: string, defaultValue: T) { const stored = localStorage.getItem(key) const data = ref<T>(stored ? JSON.parse(stored) : defaultValue) as Ref<T> watch(data, (val) => { localStorage.setItem(key, JSON.stringify(val)) }, { deep: true }) return data }
const theme = useLocalStorage('theme', 'dark') const filters = useLocalStorage('dashboard-filters', { status: 'all', sort: 'date' })
Now your component state survives page refreshes without any extra wiring.
useAsyncState
This one encapsulates the "loading, error, data" pattern that shows up in every component that fetches something:
export function useAsyncState<T>(fn: () => Promise<T>) { const data = ref<T | null>(null) as Ref<T | null> const error = ref<Error | null>(null) const loading = ref(false) const execute = async () => { loading.value = true error.value = null try { data.value = await fn() } catch (e) { error.value = e instanceof Error ? e : new Error(String(e)) } finally { loading.value = false } } return { data, error, loading, execute } }
const { data: users, loading, error, execute: fetchUsers } = useAsyncState( () => api.getUsers() ) onMounted(fetchUsers)
If you're in Nuxt, you've got useAsyncData which covers this. But in a plain Vue app, this saves a lot of repetitive boilerplate.
useClickOutside
Dropdowns, modals, popovers — anything that should close when you click outside of it:
export function useClickOutside( target: Ref<HTMLElement | null>, handler: () => void ) { const listener = (event: MouseEvent) => { if (!target.value || target.value.contains(event.target as Node)) return handler() } onMounted(() => document.addEventListener('click', listener)) onUnmounted(() => document.removeEventListener('click', listener)) }
<script setup> const dropdown = ref<HTMLElement | null>(null) const isOpen = ref(false) useClickOutside(dropdown, () => { isOpen.value = false }) </script> <template> <div ref="dropdown"> <button @click="isOpen = !isOpen">Menu</button> <div v-if="isOpen">Dropdown content</div> </div> </template>
useFormField
This is the one most people don't write but should. If your app has forms — and it probably does — you end up repeating validation logic everywhere. Extract the pattern:
export function useFormField<T>(initialValue: T, validate: (val: T) => string | null) { const value = ref(initialValue) as Ref<T> const touched = ref(false) const error = computed(() => touched.value ? validate(value.value) : null) const isValid = computed(() => validate(value.value) === null) const blur = () => { touched.value = true } const reset = () => { value.value = initialValue touched.value = false } return { value, error, isValid, touched, blur, reset } }
const email = useFormField('', (val) => { if (!val) return 'Email is required' if (!val.includes('@')) return 'Invalid email' return null }) const name = useFormField('', (val) => { return val.length < 2 ? 'Name is too short' : null })
Each field manages its own state, validation, and touched status. No form library needed for most cases.
The Pattern Worth Noticing
Every composable above follows the same shape: take some reactive state, add behaviour to it, return the pieces the component needs. That's it. No magic, no framework within a framework.
The mistake I see most often is developers reaching for a library before asking "could I write this in 15 lines?" Usually the answer is yes, and your version will be tailored to exactly what your app needs instead of covering every edge case you'll never hit.
Write composables that are specific to your app. Extract them when you catch yourself copying the same pattern between components. Keep them small. That's the whole philosophy.
When to Reach for VueUse
VueUse is excellent and I use it on most projects. But I use it for the things that are genuinely complex to implement correctly — useIntersectionObserver, useWebSocket, useMediaQuery. The stuff where browser API quirks and edge cases matter.
For application-level patterns like the ones above, writing your own is almost always the better call. You understand it completely, you can modify it freely, and you don't inherit someone else's API decisions.
Final Thoughts
Composables aren't a Vue feature. They're a design pattern. The Composition API gave us the tools, but the value comes from recognising repeated patterns in your own codebase and extracting them into reusable pieces.
If you open any component in your app and see a ref, a watch, and some logic that you've seen in three other components — that's a composable waiting to be written. Fifteen minutes of extraction saves hours of duplication.
Stay in the loop.
Weekly insights on building resilient systems, scaling solo SaaS, and the engineering behind it all.