1. What is Composable?
Composable is a function that uses the Composition API to encapsulate and reuse stateful logic. Convention: give names starting with use.
// composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue'
export function useFetch<T>(url: string | Ref<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const isLoading = ref(false)
async function execute() {
isLoading.value = true
error.value = null
try {
const rawUrl = typeof url === 'string' ? url : url.value
const res = await fetch(rawUrl)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
error.value = e as Error
} finally {
isLoading.value = false
}
}
watchEffect(() => { execute() })
return { data, error, isLoading, refresh: execute }
}
2. Use Composable
<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'
interface Post {
id: string
title: string
}
const { data: posts, isLoading, error, refresh } = useFetch<Post[]>('/api/posts')
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<div v-else>
<button @click="refresh">Refresh</button>
<div v-for="post in posts" :key="post.id">{{ post.title }}</div>
</div>
</template>
3. Common Composables
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const data = ref<T>(stored ? JSON.parse(stored) : defaultValue)
watch(data, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
}, { deep: true })
return data
}
// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'
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
}
// composables/useMediaQuery.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMediaQuery(query: string) {
const matches = ref(false)
let mediaQuery: MediaQueryList
onMounted(() => {
mediaQuery = window.matchMedia(query)
matches.value = mediaQuery.matches
mediaQuery.addEventListener('change', handler)
})
function handler(e: MediaQueryListEvent) {
matches.value = e.matches
}
onUnmounted(() => {
mediaQuery?.removeEventListener('change', handler)
})
return matches
}
4. VueUse Library
npm install @vueuse/core
<script setup lang="ts">
import {
useDark,
useToggle,
useLocalStorage,
useWindowSize,
useIntersectionObserver,
onClickOutside,
} from '@vueuse/core'
// Dark mode
const isDark = useDark()
const toggleDark = useToggle(isDark)
// Persistent state
const settings = useLocalStorage('settings', { theme: 'light', lang: 'vi' })
// Window size
const { width, height } = useWindowSize()
// Intersection observer
const target = ref(null)
const isVisible = ref(false)
useIntersectionObserver(target, ([entry]) => {
isVisible.value = entry.isIntersecting
})
// Click outside
const dropdown = ref(null)
onClickOutside(dropdown, () => { isOpen.value = false })
</script>
5. Composable Patterns
// Pattern: Shared state across components
// composables/useSharedCounter.ts
const globalCount = ref(0) // Shared state
export function useSharedCounter() {
function increment() { globalCount.value++ }
function decrement() { globalCount.value-- }
return { count: globalCount, increment, decrement }
}
Next article: Forms, Validation & Styling — VeeValidate, Tailwind CSS, component libraries.