What You’ll Build and Learn
If you’re new to Vue 3 or you’ve heard the buzz about the Composition API, this guide is your friendly on-ramp. You’ll learn the mental model behind ref
, reactive
, computed
, watch
, lifecycle hooks, and how to package logic into reusable composables. Along the way we’ll write bite-sized examples and finish with a tiny Todo app you can extend.
Who This Guide Is For
- JavaScript devs comfortable with ES6.
- Vue 2 users curious about Vue 3.
- Beginners who want a clear, practical introduction without fluff.
Prerequisites and Setup
- Node 18+ is ideal.
- Create a project quickly with Vite:
npm create vite@latest my-vue3-app -- --template vue
cd my-vue3-app
npm install
npm run dev
Open the project in your editor and you’re set.
Vue 3 at a Glance
Vue 3 is a progressive framework for building user interfaces. It’s fast, approachable, and scales from tiny widgets to full apps.
What Changed from Vue 2
- Composition API for composing logic by feature, not by option.
- Performance improvements and a smaller runtime.
- TypeScript support is first-class.
- Fragments, Teleport, and Suspense out of the box.
Why Choose Vue 3 in 2025
- A thriving ecosystem (Vite, Pinia, Vue Router).
- Gentle learning curve compared to some alternatives.
- Excellent DX with
<script setup>
and top-levelawait
.
Options API vs. Composition API
Both APIs render the same templates. The difference is how you organize code.
The “Logic Grouping” Problem
In the Options API, related logic gets scattered across data
, methods
, computed
, watch
. With Composition API, you keep all logic for a feature together inside setup()
or a reusable function.
When the Options API Still Makes Sense
- Tiny components with minimal logic.
- Teams migrating gradually.
- Developers who prefer a more declarative, “kitchen-labeled” structure.
Your First Composition API Component
Using <script setup>
<script setup>
is the idiomatic way to write single-file components in Vue 3. It’s compiled away, so your code is lean and fast.
<script setup>
import { ref, computed, onMounted } from 'vue'
const count = ref(0) // primitive or single value state
const double = computed(() => count.value * 2)
function increment() {
count.value++
}
onMounted(() => {
console.log('Component mounted!')
})
</script>
<template>
<button @click="increment">
Clicked {{ count }} times (x2 = {{ double }})
</button>
</template>
State with ref
ref(0)
wraps a value and exposes it as.value
.- In templates, Vue unwraps refs automatically, so
{{ count }}
just works.
Derived State with computed
computed
caches results until its dependencies change.- Perfect for totals, filters, formatting, or expensive calculations.
reactive
, toRef
, and toRefs
reactive
makes an object reactive—great for grouped state.
import { reactive, toRefs, toRef } from 'vue'
const state = reactive({
user: { id: 1, name: 'Ada' },
items: [],
loading: false
})
// toRefs preserves reactivity when destructuring:
const { user, items, loading } = toRefs(state)
// single property as a ref:
const itemCount = toRef(state, 'items')
When to Prefer reactive
- When state is naturally an object or you have many related fields.
- When you want to pass around a single container of state.
Avoiding Destructuring Reactivity Gotchas
Don’t do:
const { user } = state // ❌ loses reactivity
Do:
const { user } = toRefs(state) // ✅ keeps reactivity
Lifecycle Hooks in Composition API
Import and call hooks directly in setup()
.
import { onMounted, onUpdated, onUnmounted } from 'vue'
onMounted(() => { /* subscribe, fetch, focus */ })
onUpdated(() => { /* respond to DOM changes */ })
onUnmounted(() => { /* cleanup timers, listeners */ })
Common Hooks (onMounted
, onUnmounted
, onUpdated
)
- Use
onMounted
for fetching remote data or DOM work. - Always clean up in
onUnmounted
to avoid leaks.
Route-Aware Hooks (Heads-Up)
With Vue Router you also get:
onBeforeRouteLeave
to block navigation (e.g., unsaved changes).onBeforeRouteUpdate
to react when route params change.
Watchers: watch vs watchEffect
import { ref, watch, watchEffect } from 'vue'
const query = ref('')
const results = ref([])
watch(query, async (newQ, oldQ) => {
results.value = await search(newQ)
})
watchEffect(() => {
// Runs immediately and tracks dependencies it touches
console.log('Current results length:', results.value.length)
})
Deep Watching and Cleanup
watch(obj, cb, { deep: true })
observes nested changes—but prefer computed values or targeted refs to avoid heavy work.- Return a cleanup function in
watchEffect
if you start effects like intervals or subscriptions.
Performance Tips for Watchers
- Watch the minimal source (
() => state.items.length
) rather than the whole object. - Debounce expensive operations when responding to rapid input.
Template Refs and DOM Access
<template>
<input ref="inputEl" placeholder="Focus me on mount" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const inputEl = ref(null)
onMounted(() => {
inputEl.value?.focus()
})
</script>
Focusing Inputs and Reading Sizes
Use template refs for focusing, measuring elements, or integrating non-Vue libraries.
shallowRef
for Non-Vue Objects
For large third-party instances (e.g., a chart), use shallowRef
to avoid deep proxying:
import { shallowRef } from 'vue'
const chart = shallowRef(null)
Props, Emits, and Typing
defineProps
/ defineEmits
<script setup lang="ts">
const props = defineProps<{
modelValue: string
max?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'limit'): void
}>()
function onInput(v: string) {
if (props.max && v.length > props.max) return emit('limit')
emit('update:modelValue', v)
}
</script>
<template>
<input :value="modelValue" @input="onInput(($event.target as HTMLInputElement).value)" />
</template>
TypeScript-Friendly Patterns
Using <script setup lang="ts">
gives strong types for props and emits, catching mistakes early.
Provide/Inject for Cross-Tree State
Pass data to deep descendants without prop drilling.
// Parent
import { provide } from 'vue'
provide('theme', 'dark')
// Deep child
import { inject } from 'vue'
const theme = inject('theme', 'light') // 'light' is a fallback
Scoped Injections
Provide different tokens per subtree to vary behavior (e.g., different themes for modals vs. main app).
Fallbacks and Debugging
Always give a fallback to avoid undefined
surprises in tests or storybook environments.
Building Reusable Composables
A useCounter
Example
// composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0, step = 1) {
const count = ref(initial)
const double = computed(() => count.value * 2)
const inc = () => (count.value += step)
const dec = () => (count.value -= step)
const reset = () => (count.value = initial)
return { count, double, inc, dec, reset }
}
Use it in any component:
const { count, inc, double } = useCounter(10, 2)
A useFetch
Example with Error Handling
// composables/useFetch.ts
import { ref, onMounted } from 'vue'
export function useFetch<T = unknown>(url: string, immediate = true) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
data.value = (await res.json()) as T
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
if (immediate) onMounted(execute)
return { data, error, loading, execute }
}
Async and Suspense
Top-Level await
in <script setup>
You can await
directly:
<script setup>
const user = await (await fetch('/api/me')).json()
</script>
<template>
<pre>{{ user }}</pre>
</template>
Graceful Loading States
Wrap async components with <Suspense>
and provide a fallback:
<Suspense>
<template #default>
<UserCard />
</template>
<template #fallback>
<div>Loading user…</div>
</template>
</Suspense>
Routing with Vue Router
useRoute
and useRouter
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
function goHome() {
router.push({ name: 'home' })
}
onBeforeRouteLeave
/ onBeforeRouteUpdate
Keep forms safe and respond to param changes:
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteLeave((to, from) => {
// return false to cancel
})
onBeforeRouteUpdate((to, from) => {
// react to route param changes
})
State Management with Pinia
Defining a Store
// stores/cart.ts
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as { id: number; name: string }[] }),
getters: {
count: (s) => s.items.length,
},
actions: {
add(item: { id: number; name: string }) {
this.items.push(item)
},
remove(id: number) {
this.items = this.items.filter((i) => i.id !== id)
},
},
})
Using storeToRefs
in Components
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { items, count } = storeToRefs(cart)
function addRandom() {
cart.add({ id: Date.now(), name: 'Gizmo' })
}
</script>
<template>
<p>Items: {{ count }}</p>
<ul><li v-for="i in items" :key="i.id">{{ i.name }}</li></ul>
<button @click="addRandom">Add</button>
</template>
Forms and Validation
v-model
with the Composition API
v-model
works the same; for custom components, emit update:modelValue
.
<script setup>
const text = ref('')
</script>
<template>
<input v-model="text" placeholder="Type here" />
<p>You typed: {{ text }}</p>
</template>
Validation Approaches
- Light: Hand-roll checks in
watch
/computed
. - Medium: Composables that return
{ errors, validate }
. - Heavy: Libraries like VeeValidate or custom schema checkers.
Testing Components and Composables
Unit-Testing a Composable with Vitest
// composables/useCounter.spec.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('increments and doubles', () => {
const { count, inc, double } = useCounter(1, 2)
inc()
expect(count.value).toBe(3)
expect(double.value).toBe(6)
})
})
Component Testing Basics
Use Testing Library or Vue Test Utils to render components and assert text or DOM behavior.
Migration Tips from Options to Composition
Mapping Options to setup()
data
→ref
/reactive
computed
→computed
methods
→ plain functionswatch
→watch
/watchEffect
mounted
→onMounted
, etc.
Progressively Adopting Composition API
You can mix APIs in the same app. Convert complex components first to reap the benefits of grouped logic, and leave simple ones on Options for now.
Common Pitfalls and How to Fix Them
Losing Reactivity via Destructuring
Problem:
const { user } = state // ❌ breaks tracking
Fix:
const { user } = toRefs(state) // ✅
Overusing Deep Watch
Avoid deep: true
unless you truly need it. Often you can watch a specific computed value instead, which is faster and easier to reason about.
Other quick wins:
- Don’t mutate props; create local state.
- Cleanup effects in
onUnmounted
. - Prefer
computed
overwatch
when deriving values.
Mini Project: A Tiny Todo with Filters
Folder Structure and Store
src/
composables/
useTodos.ts
components/
TodoInput.vue
TodoList.vue
App.vue
useTodos.ts
import { ref, computed } from 'vue'
export type Todo = { id: number; text: string; done: boolean }
export function useTodos() {
const todos = ref<Todo[]>([])
const filter = ref<'all' | 'active' | 'done'>('all')
const visible = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.done)
if (filter.value === 'done') return todos.value.filter(t => t.done)
return todos.value
})
function add(text: string) {
const clean = text.trim()
if (!clean) return
todos.value.push({ id: Date.now(), text: clean, done: false })
}
function toggle(id: number) {
const t = todos.value.find(t => t.id === id)
if (t) t.done = !t.done
}
function remove(id: number) {
todos.value = todos.value.filter(t => t.id !== id)
}
return { todos, filter, visible, add, toggle, remove }
}
TodoInput.vue
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['add'])
const text = ref('')
const submit = () => {
emit('add', text.value)
text.value = ''
}
</script>
<template>
<form @submit.prevent="submit" class="row">
<input v-model="text" placeholder="What needs doing?" />
<button>Add</button>
</form>
</template>
TodoList.vue
<script setup>
const props = defineProps({
items: { type: Array, required: true },
})
const emit = defineEmits(['toggle', 'remove'])
</script>
<template>
<ul>
<li v-for="t in items" :key="t.id">
<label>
<input type="checkbox" :checked="t.done" @change="emit('toggle', t.id)" />
<span :style="{ textDecoration: t.done ? 'line-through' : 'none' }">{{ t.text }}</span>
</label>
<button @click="emit('remove', t.id)">×</button>
</li>
</ul>
</template>
App.vue
<script setup>
import { useTodos } from './composables/useTodos'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'
const { visible, filter, add, toggle, remove } = useTodos()
</script>
<template>
<h1>Todos</h1>
<TodoInput @add="add" />
<div>
<button @click="filter = 'all'">All</button>
<button @click="filter = 'active'">Active</button>
<button @click="filter = 'done'">Done</button>
</div>
<TodoList :items="visible" @toggle="toggle" @remove="remove" />
</template>
This tiny app demonstrates refs, computed, emits, and composables—exactly the heart of the Composition API.
Adding a Composable and Tests
Extract filtering logic or persistence (e.g., useLocalStorage
) into a composable. Then unit-test it with Vitest to ensure it behaves under edge cases.
Next Steps and Learning Path
Recommended Ecosystem Tools
- Vite for bundling and dev server.
- Pinia for global state.
- Vue Router for navigation.
- Vitest for testing.
- ESLint + Prettier for consistent code.
Where to Go From Here
- Convert an existing Options API component to Composition API.
- Write your first reusable composable and publish it internally.
- Layer TypeScript for stronger safety as your project grows.
Conclusion
The Composition API in Vue 3 gives you a clean, scalable way to organize component logic, especially as features become more complex. With ref
, reactive
, computed
, and watchers, you can model state precisely. Lifecycle hooks keep effects tidy, while composables let you package and share behavior across components. Add Vue Router and Pinia, and you’ve got a modern, productive stack ready for real-world apps. Start small—convert one component, extract one composable—and you’ll quickly feel the difference in clarity and maintainability.
FAQs
1) Is the Composition API harder than the Options API?
Not really—just different. Once you write a few components with <script setup>
, the mental model clicks.
2) Do I have to rewrite my whole app to use Composition API?
No. Mix both APIs. Move complex components first and leave simple ones on Options until it’s worth changing.
3) Should I use ref
or reactive
for arrays and objects?
Both work. Use ref<T[]>([])
when you frequently reassign the whole array; use reactive({ items: [] })
when the collection is part of a larger state object.
4) When should I choose watch
over computed
?computed
is for deriving values; watch
is for side effects (fetching, logging, saving).
5) Do I need Pinia if I have composables?
Composables are great for logic reuse. If you need cross-page state with devtools, persistence, and structure, Pinia is a natural fit.