Vue.js Tips and Tricks: Patterns You Should Be Using
Stop Writing Options API in Composition API Style
The most common mistake when switching to Composition API: writing the same logic, just with setup(). The Composition API isn't just syntactic sugar — it's a fundamentally different model for organizing code by feature instead of by option type.
// ❌ Options API thinking in Composition API
setup() {
const data = reactive({
users: [],
loading: false,
error: null,
})
async function fetchUsers() {
data.loading = true
// ...
}
return { ...toRefs(data), fetchUsers }
}
// ✅ Composition API thinking — extracted into a composable
// useUsers.js
export function useUsers() {
const users = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchUsers() {
loading.value = true
try {
users.value = await api.getUsers()
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
return { users, loading, error, fetchUsers }
}
Now your component just calls const { users, loading, fetchUsers } = useUsers() and the logic lives somewhere testable and reusable.
Composables Are Your New Mixins (But Better)
Composables solve the same problem as mixins — sharing stateful logic — without the naming collisions, unclear data sources, and implicit dependencies that made mixins painful.
// useLocalStorage.js
export function useLocalStorage(key, defaultValue) {
const value = ref(JSON.parse(localStorage.getItem(key)) ?? defaultValue)
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return value
}
// In any component
const theme = useLocalStorage('theme', 'dark')
const sidebarOpen = useLocalStorage('sidebar', true)
The source of theme is explicit. No magic. No collisions.
v-model on Custom Components
Custom v-model is one of Vue's best features and one of the most underused. Instead of passing modelValue props and emitting update:modelValue manually, define it cleanly:
<!-- InputField.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
Vue 3 also supports multiple v-model bindings on one component:
<UserForm
v-model:name="user.name"
v-model:email="user.email"
/>
// UserForm.vue
defineProps(['name', 'email'])
defineEmits(['update:name', 'update:email'])
Async Components and Suspense
Don't import heavy components eagerly. Use defineAsyncComponent to split the bundle and load on demand.
import { defineAsyncComponent } from 'vue'
const HeavyChart = defineAsyncComponent(() =>
import('./components/HeavyChart.vue')
)
Pair with <Suspense> to handle the loading state declaratively:
<Suspense>
<template #default>
<HeavyChart :data="chartData" />
</template>
<template #fallback>
<Skeleton height="300px" />
</template>
</Suspense>
watchEffect vs watch — Know the Difference
watch is explicit: you declare what to watch and what to do when it changes.
watchEffect is implicit: it runs immediately and automatically tracks any reactive dependency it reads.
// watch — explicit, runs only when userId changes
watch(userId, async (id) => {
user.value = await fetchUser(id)
}, { immediate: true })
// watchEffect — implicit, tracks userId automatically
watchEffect(async () => {
user.value = await fetchUser(userId.value)
})
watchEffect is great for syncing state. watch is better when you need the old value or fine control over when it triggers. Don't default to one — pick the right tool.
Provide / Inject for Deep Trees
Prop drilling through three or four component levels is a sign you should use provide/inject. It's Vue's built-in dependency injection.
// Parent or App.vue
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme) // provide the ref so it's reactive
// Any descendant, no matter how deep
import { inject } from 'vue'
const theme = inject('theme')
For larger apps, wrap provide/inject in composables with typed keys to avoid string collisions and get autocomplete.
shallowRef for Large Objects
When you have a large object that Vue doesn't need to deeply track, shallowRef avoids the overhead of deep reactivity conversion.
// ❌ Vue walks the entire tree making every nested property reactive
const config = ref(massiveConfigObject)
// ✅ Only the ref itself is reactive — replacing it triggers updates,
// but internal mutations do not (which is what you want here)
const config = shallowRef(massiveConfigObject)
// To update, replace the whole object:
config.value = { ...config.value, timeout: 5000 }
Use shallowRef for external data, API responses stored in refs, or any object you replace wholesale rather than mutate deeply.
Enjoyed this post?
Subscribe to the newsletter
Get future posts delivered to your inbox. No spam, unsubscribe anytime.