Composables: The Heart of Vue 3
If you're still putting all your logic in setup() or using Options API for complex components, composables will change your life. They're simple functions that encapsulate reactive logic and can be shared across components.
// composables/useDebounce.js
import { ref, watch } from 'vue'
export function useDebounce(value, delay = 300) {
const debouncedValue = ref(value.value)
let timeout
watch(value, (newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
// Usage in a component
const search = ref('')
const debouncedSearch = useDebounce(search, 500)
watch(debouncedSearch, (value) => {
fetchResults(value)
})
A More Practical Composable: API Fetching
// composables/useApi.js
import { ref, shallowRef } from 'vue'
export function useApi() {
const data = shallowRef(null)
const error = ref(null)
const loading = ref(false)
async function execute(url, options = {}) {
loading.value = true
error.value = null
try {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
data.value = await response.json()
return data.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
return { data, error, loading, execute }
}
// Usage
const { data: users, loading, execute: fetchUsers } = useApi()
await fetchUsers('/api/users')
Provide/Inject: Skip the Prop Drilling
When you need to pass data through many component layers, provide and inject are cleaner than prop drilling.
// In a parent component
import { provide, ref } from 'vue'
const currentTheme = ref('dark')
const toggleTheme = () => {
currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark'
}
provide('theme', { currentTheme, toggleTheme })
// In any descendant (no matter how deep)
import { inject } from 'vue'
const { currentTheme, toggleTheme } = inject('theme')
v-model with Components: The Right Way
Vue 3's defineModel macro (3.4+) makes custom v-model support trivial:
<!-- BaseInput.vue -->
<script setup>
const model = defineModel()
const props = defineProps({
label: String,
type: { type: String, default: 'text' },
})
</script>
<template>
<div class="form-group">
<label>{{ label }}</label>
<input :type="type" v-model="model" class="input" />
</div>
</template>
<!-- Usage -->
<BaseInput v-model="form.email" label="Email" type="email" />
Performance: Lazy Loading Routes
Don't load your entire app upfront. Split by route:
// router/index.js
const routes = [
{
path: '/',
component: () => import('./views/Home.vue'),
},
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'),
// Only loads when user navigates to /dashboard
},
{
path: '/settings',
component: () => import('./views/Settings.vue'),
},
]
Watchers: Use watchEffect for Simple Cases
// Instead of manually listing dependencies...
watch([firstName, lastName], ([first, last]) => {
fullName.value = `${first} ${last}`
})
// ...let Vue track them automatically
watchEffect(() => {
fullName.value = `${firstName.value} ${lastName.value}`
})
Template Refs with TypeScript
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
onMounted(() => {
inputRef.value?.focus()
})
</script>
<template>
<input ref="inputRef" />
</template>
Quick Tips Roundup
- Use
shallowReffor large objects that don't need deep reactivity — it's significantly faster - Prefer
computedover methods in templates — computed values are cached - Use
toRefswhen destructuring reactive objects to preserve reactivity - Add
keyattributes tov-forloops with unique IDs, not array indices - Use
<Suspense>with async components for loading states - Keep components under 200 lines — if it's longer, extract a composable or child component
The best Vue code reads like a description of what the UI does, not how it does it. Composables handle the "how," components describe the "what."