Vue 3 composables you actually reuse
Nine Vue 3 composables covering the reactive plumbing every app rebuilds: breakpoints, media queries, theme, clipboard, drag and drop, intersection observation, debounced refs, platform detection and view mode.
A composable is worth extracting when it owns something the component should not: a listener that must be removed, a matchMedia query that must be re-evaluated, a timer that must be cleared. All nine below exist for that reason — the reuse is a side effect, the lifecycle management is the point.
Cleanup is the whole job
The bug these prevent is always the same. A component adds a resize listener in onMounted, the component unmounts, the listener stays, and the callback runs against a reactive ref that no longer belongs to a mounted tree. In development it looks like nothing; in a long-lived SPA it accumulates until scrolling gets slow.
Every composable here registers its teardown with onScopeDispose or onUnmounted, which means a component that uses it cannot forget to clean up. That is worth more than the lines saved.
Media queries beat resize listeners
useBreakpoints and useMediaQuery both wrap window.matchMedia rather than listening to resize. The difference is real: matchMedia fires only when the query result actually changes, while resize fires continuously during a window drag and forces you to debounce and re-measure. It also matches the same breakpoints your CSS uses, so the JavaScript and the stylesheet cannot disagree.
useMediaQuery covers the non-width queries too — prefers-color-scheme, prefers-reduced-motion, pointer: coarse — which is how useTheme follows the system setting and how a UI can honour a user who has asked for less animation.
SSR is where composables break
Nuxt renders components on the server, where window, document, navigator and localStorage do not exist. A composable that touches any of them at setup time crashes the build rather than the browser. The pattern used throughout is to initialise with a safe default and read the real value in onMounted, which also avoids the hydration mismatch you get when the server guesses one thing and the client finds another.
useTheme and useViewMode both persist to localStorage, so both need this treatment: the server has no idea what the user picked last time, and pretending otherwise produces a visible flash on load.
Clipboard and drag-and-drop are permission-shaped
navigator.clipboard.writeText returns a promise that rejects — when the page is not focused, when the document is not served over HTTPS, or when the user denies the permission. Treating the write as guaranteed produces a button that silently does nothing. The composable surfaces the failure so the UI can say so.
useBreakpoints hook
A Vue composable that reactively tracks the viewport width and exposes isMobile/isTablet/isDesktop flags.
How it works: a single resize listener updates a reactive windowWidth ref, and three computed flags derive from it — so any component using this hook re-renders automatically when the viewport crosses a breakpoint.
import { ref, onMounted, onUnmounted, computed } from 'vue'
export function useBreakpoints() {
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 0)
const onResize = () => {
windowWidth.value = window.innerWidth
}
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
const isMobile = computed(() => windowWidth.value < 768)
const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1024)
const isDesktop = computed(() => windowWidth.value >= 1024)
return { windowWidth, isMobile, isTablet, isDesktop }
}
// Example usage
const { isMobile } = useBreakpoints()
useClipboardCopy hook
A Vue composable that copies text to the clipboard with a fallback for older browsers and a brief "copied" state.
How it works: it prefers the modern navigator.clipboard.writeText API, falling back to a hidden <textarea> + document.execCommand('copy') for older/insecure contexts, and exposes a copied flag that auto-resets after 1.5s for UI feedback.
import { ref } from 'vue'
export function useClipboardCopy() {
const isCopying = ref(false)
const copied = ref(false)
async function copy(text) {
if (!text) return false
isCopying.value = true
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.opacity = '0'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
copied.value = true
setTimeout(() => { copied.value = false }, 1500)
return true
} catch {
return false
} finally {
isCopying.value = false
}
}
return { copy, isCopying, copied }
}
useDebouncedRef — Delayed Reactive Value
A Vue composable returning a pair of refs: a "live" value and a debounced version that only updates after a pause in input. Perfect for search queries and filters.
How it works: value updates immediately (for the UI); debounced only updates after delay ms have passed since the last change. A watcher on value clears and restarts the timer on every new input.
import { ref, watch } from 'vue'
export function useDebouncedRef(initialValue, delay = 300) {
const value = ref(initialValue)
const debounced = ref(initialValue)
let timer = null
watch(value, (newVal) => {
clearTimeout(timer)
timer = setTimeout(() => {
debounced.value = newVal
}, delay)
})
return { value, debounced }
}
// Example usage — search with debounce
const { value: query, debounced: debouncedQuery } = useDebouncedRef('', 400)
// Hit the API only when the user stops typing
watch(debouncedQuery, (q) => {
if (q.length >= 2) fetchResults(q)
})
<template>
<input v-model="query" placeholder="Search..." />
<span v-if="query !== debouncedQuery">Typing…</span>
</template>
useDragAndDrop hook
A Vue composable that wires up drag-and-drop file uploads — tracks drag state and hands dropped files to your callback.
How it works: a drag counter (rather than a single boolean) tracks nested dragenter/dragleave events correctly, since the browser fires them for every child element the cursor passes over.
import { ref } from 'vue'
export function useDragAndDrop(options) {
const isDragging = ref(false)
let dragCounter = 0
function onDragEnter(e) {
e.preventDefault()
if (!e.dataTransfer) return
dragCounter++
if (options.validateDrag) {
isDragging.value = options.validateDrag(e.dataTransfer)
} else if (e.dataTransfer.types.includes('Files')) {
isDragging.value = true
}
}
function onDragLeave(e) {
e.preventDefault()
dragCounter = Math.max(0, dragCounter - 1)
if (dragCounter === 0) isDragging.value = false
}
function onDragOver(e) {
e.preventDefault()
}
function onDrop(e) {
e.preventDefault()
dragCounter = 0
isDragging.value = false
const files = e.dataTransfer?.files
if (files?.length) options.onDrop(files)
}
return {
isDragging,
dragHandlers: { dragenter: onDragEnter, dragleave: onDragLeave, dragover: onDragOver, drop: onDrop }
}
}
// Example usage — spread dragHandlers onto a drop-zone element in your template
const { isDragging, dragHandlers } = useDragAndDrop({
onDrop: (files) => console.log('Dropped', files.length, 'file(s)')
})
useIntersectionObserver — Vue Composable
A Vue composable for tracking element visibility in the viewport — great for lazy-loading images, scroll animations, and infinite scroll.
How it works: creates an IntersectionObserver after the component mounts and attaches it to the given target ref. isIntersecting updates reactively. hasIntersected stays true after the first appearance — useful for one-shot animations.
import { ref, onMounted, onUnmounted } from 'vue'
export function useIntersectionObserver(options = {}) {
const target = ref(null)
const isIntersecting = ref(false)
const hasIntersected = ref(false)
let observer = null
onMounted(() => {
if (!target.value) return
observer = new IntersectionObserver(([entry]) => {
isIntersecting.value = entry.isIntersecting
if (entry.isIntersecting) hasIntersected.value = true
}, { threshold: 0.1, ...options })
observer.observe(target.value)
})
onUnmounted(() => observer?.disconnect())
return { target, isIntersecting, hasIntersected }
}
<!-- Example usage — fade-in when entering the viewport -->
<script setup>
import { useIntersectionObserver } from '~/composables/useIntersectionObserver'
const { target, hasIntersected } = useIntersectionObserver({ threshold: 0.2 })
</script>
<template>
<div
ref="target"
:class="['card', hasIntersected ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4']"
class="transition-all duration-500"
>
Content fades in on scroll
</div>
</template>
useMediaQuery — Reactive Media Query
A Vue composable that returns a reactive ref updated whenever a CSS media query result changes. SSR-safe — does not throw on the server.
How it works: uses window.matchMedia to create a media query listener and subscribes to the change event. The typeof window check makes it safe for SSR/Nuxt.
import { ref, onMounted, onUnmounted } from 'vue'
export function useMediaQuery(query) {
const matches = ref(false)
let mql = null
function onchange(e) {
matches.value = e.matches
}
onMounted(() => {
mql = window.matchMedia(query)
matches.value = mql.matches
mql.addEventListener('change', onchange)
})
onUnmounted(() => {
mql?.removeEventListener('change', onchange)
})
return matches
}
// Example usage
const isDesktop = useMediaQuery('(min-width: 1024px)')
const isMobile = useMediaQuery('(max-width: 767px)')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)')
<template>
<nav v-if="isDesktop">Desktop navigation</nav>
<MobileMenu v-else />
</template>
usePlatform hook
Detects macOS vs. other platforms so you can show the right keyboard shortcut symbol (⌘ vs Ctrl).
How it works: navigator.platform reports the OS the browser runs on; matching it against Mac/iPod/iPhone/iPad lets you pick the correct modifier-key symbol for shortcut hints in your UI.
import { computed } from 'vue'
export function usePlatform() {
const isMac = /MAC|IPOD|IPHONE|IPAD/.test(navigator.platform?.toUpperCase() || '')
const primaryKeySymbol = computed(() => (isMac ? '⌘' : 'Ctrl'))
return { isMac, primaryKeySymbol }
}
// Example usage — show the right shortcut hint per OS
const { primaryKeySymbol } = usePlatform()
console.log(`Save: ${primaryKeySymbol.value}+S`)
useTheme hook
A Vue composable for light/dark/system theme detection and switching, persisted to localStorage.
How it works: the current choice ('light' / 'dark' / 'system') is read from localStorage, and a matchMedia listener keeps systemIsDark in sync so isDark stays correct even when the user picked "system" and their OS theme changes.
import { ref, computed, onMounted, onUnmounted } from 'vue'
export function useTheme() {
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('theme') : null
const theme = ref(stored || 'system')
const systemIsDark = ref(false)
const mediaQuery = () =>
typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)') : null
const isDark = computed(() =>
theme.value === 'system' ? systemIsDark.value : theme.value === 'dark'
)
function setTheme(value) {
theme.value = value
localStorage.setItem('theme', value)
}
const onSystemChange = (e) => { systemIsDark.value = e.matches }
onMounted(() => {
const mq = mediaQuery()
systemIsDark.value = mq?.matches ?? false
mq?.addEventListener('change', onSystemChange)
})
onUnmounted(() => mediaQuery()?.removeEventListener('change', onSystemChange))
return { theme, isDark, setTheme }
}
// Example usage
const { isDark, setTheme } = useTheme()
setTheme('dark') // 'light' | 'dark' | 'system'
useViewMode hook
Toggle between grid/list view and persist the choice to localStorage across visits.
How it works: the mode is read from localStorage on mount and written back on every change via watch, so a user's grid/list preference survives page reloads.
import { ref, onMounted, watch } from 'vue'
export function useViewMode({ storageKey = 'view_mode', defaultMode = 'grid' } = {}) {
const mode = ref(defaultMode)
onMounted(() => {
const saved = localStorage.getItem(storageKey)
if (saved === 'grid' || saved === 'list') mode.value = saved
})
watch(mode, (value) => localStorage.setItem(storageKey, value))
const toggle = () => { mode.value = mode.value === 'grid' ? 'list' : 'grid' }
return { mode, setMode: (v) => (mode.value = v), toggle }
}
// Example usage
const { mode, toggle } = useViewMode()
More snippet collections
Contact Information
Submit a request or write to me directly on Telegram. I'll reply within 24 hours.