Level up your Vue 3 apps with ten practical tips—from script setup and smart reactivity to Teleport, Suspense, async components, and performance wins. Clear examples you can drop right into your project.
If you’ve shipped even one Vue app, you know the difference between “it works” and “it feels fast and clean.” These ten tips are the things I wish someone had told me when I moved full-time to Vue 3. They’re simple, battle-tested, and copy-paste friendly.
New to the bigger picture? Read my overview of how front-end evolved: Evolution of Front-End Development (2010–2025).
Table of contents
- Use
script setupto cut boilerplate - Type your props & emits (even without a full TS migration)
- Reactivity:
refvsreactive—and when to usetoRef(s) - Computed setters for two-way derived state
- Watchers:
watchvswatchEffectand common pitfalls - Async components + route-level code-splitting
- Keep overlays sane with <Teleport>
- Make loading feel instant with <Suspense>
- Extract logic into composables
- Quick performance wins that add up
1) Use script setup to cut boilerplate
The script setup syntax removes ceremony and gives you direct template access without return.
<!-- Counter.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{ initial?: number }>()
const count = ref(props.initial ?? 0)
const double = computed(() => count.value * 2)
const increment = () => count.value++
</script>
<template>
<button @click="increment">
Count: {{ count }} (x2: {{ double }})
</button>
</template>
Level up your editor speed too: Emmet Tips & Tricks for Beginners (That You’ll Actually Use).
2) Type your props & emits (even without a full TS migration)
You don’t need a full TypeScript stack to reap benefits. Minimal types stop bugs early and provide editor autocompletion.
<script setup lang="ts">
const props = defineProps<{
modelValue: string
max?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'reach-max'): void
}>()
function onInput(val: string) {
if (props.max && val.length > props.max) {
emit('reach-max')
return
}
emit('update:modelValue', val)
}
</script>
3) Reactivity: ref vs reactive—and when to use toRef(s)
Use ref for primitives (number, string, boolean) and when you plan to replace the value. Use reactive for objects you mutate deeply. Use toRef/toRefs to create reactive “pointers” to props or fields, avoiding accidental de-reactivity with destructuring.
import { reactive, toRef, toRefs } from 'vue'
const state = reactive({ user: { name: 'Sara', age: 17 }, loading: false })
const loading = toRef(state, 'loading') // direct link to state.loading
const { user } = toRefs(state) // keep reactivity when destructuring
If JavaScript scope trips you up, this primer will help: Understanding JavaScript Scope: A Beginner’s Guide.
4) Computed setters for two-way derived state
computed isn’t just for getters. A setter lets you sync derived state elegantly.
import { ref, computed } from 'vue'
const first = ref('Riad')
const last = ref('Kilani')
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (val: string) => {
const [f, ...rest] = val.split(' ')
first.value = f
last.value = rest.join(' ')
}
})
// fullName.value = 'Sara Kilani' updates both first/last
5) Watchers: watch vs watchEffect and common pitfalls
watch(source, cb) runs when the source changes (great for params or specific refs). watchEffect(cb) tracks everything used inside the callback (great for quick side effects).
import { watch, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
watch(() => route.params.id, (id) => {
// fetch user when id changes
}, { immediate: true })
watchEffect(() => {
// runs when any accessed reactive value changes
console.log('Current path:', route.path)
})
Working with async side effects? Start here: JavaScript Promise: What It Is and How to Use It.
6) Async components + route-level code-splitting
Make big chunks lazy. Users shouldn’t download admin charts on the landing page. With Vue Router, dynamically import views so each route becomes its own chunk.
// LazyUserCard.ts
import { defineAsyncComponent } from 'vue'
export const UserCard = defineAsyncComponent(() => import('./UserCard.vue'))
<!-- Somewhere.vue -->
<script setup>
import { UserCard } from './LazyUserCard'
</script>
<template>
<UserCard />
</template>
{
path: '/reports',
component: () => import('@/views/ReportsView.vue')
}
7) Keep overlays sane with <Teleport>
Modals, toasts, and dropdowns are easier when you render them near <body> to bypass overflow and z-index battles.
<template>
<button @click="open = true">Open modal</button>
<Teleport to="body">
<div v-if="open" class="backdrop" @click="open = false">
<div class="modal" @click.stop>
<h3>Settings</h3>
<button @click="open = false">Close</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
8) Make loading feel instant with <Suspense>
Show a graceful fallback while async components or data are resolving. Pair with route-level async data or composables that return promises.
<script setup>
import { defineAsyncComponent } from 'vue'
const UserProfile = defineAsyncComponent(() => import('./UserProfile.vue'))
</script>
<template>
<Suspense>
<template #default>
<UserProfile />
</template>
<template #fallback>
<div aria-busy="true">Loading profile…</div>
</template>
</Suspense>
</template>
9) Extract logic into composables (your future self will thank you)
Move reusable logic (fetching, debounce, feature flags) into src/composables. Keep UI in components, stateful logic in composables, and make them framework-agnostic when possible.
// src/composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'
export function useDebounce<T>(source: Ref<T>, ms = 300) {
const debounced = ref(source.value) as Ref<T>
let t: ReturnType<typeof setTimeout> | null = null
watch(source, (val) => {
if (t) clearTimeout(t)
t = setTimeout(() => { debounced.value = val }, ms)
}, { immediate: true })
return debounced
}
// in a component
const query = ref('')
const debouncedQuery = useDebounce(query, 400)
// watch debouncedQuery to trigger API calls less often
10) Quick performance wins that add up
- Use
keycorrectly when rendering lists; avoid using array index as the key if items can move. - Cache heavy computations in
computedrather than recalculating in templates. v-oncefor content that never changes.- Split big components into smaller ones and lazy-load what you can.
- Throttle expensive watchers and API calls (combine with the
useDebouncecomposable above).
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
For semantic + a11y gains that also help SEO, see 10 Must-Use HTML Tags in 2025, and stay current with New CSS Features to Know in 2025.
Related Reading
- Understanding JavaScript Scope: A Beginner’s Guide
- JavaScript Promise: What It Is and How to Use It
- 10 Must-Use HTML Tags in 2025
- New CSS Features to Know in 2025
- Evolution of Front-End Development (2010–2025)
- From Portfolio to Platform: Building a SPA + WordPress Ecosystem
Curious how I run this stack in the real world? Here’s the behind-the-scenes: From Portfolio to Platform: Building a SPA + WordPress Ecosystem.
If you found this useful, share it with a teammate who’s currently fighting a modal or an infinite spinner. And if you want more deep dives (Pinia patterns? Advanced Suspense? Real-world perf budgets?), drop a comment—I read them all.
FAQ
Is Vue 3 required for these tips?
Most are Vue 3-centric (e.g., script setup, <Suspense>). If you’re on Vue 2, consider the migration path or the composition-api plugin for some patterns.
Do I need TypeScript?
No, but adding light types to props/emits improves DX even if the rest is JS.
What about state management?
Composables handle a lot, but for cross-app state and devtools, Pinia is ergonomic and pairs well with Vue 3.
How do I keep bundle size down?
Async components, route-level code splitting, and avoiding large dependencies on initial routes.
When should I use reactive vs ref?
ref for primitives or frequently replaced values; reactive for objects you mutate. Use toRef(s) to keep reactivity when destructuring.
Tip: Set the post’s Slug to vuejs-tips-and-tricks, enter the Meta description in your SEO plugin, and assign Categories: Front-End, Vue.js. Tags: Vue 3, Composition API, script setup, Teleport, Suspense, performance, async components.
Leave a Reply