Table of contents
- Why the Composition API exists
- The setup function and script setup
- Reactivity with ref and reactive
- Derived state with computed
- Watching state changes
- Reusable logic with composables
- Avoiding prop drilling with provide and inject
- Async setup and Suspense
- Router composables
- TypeScript support in Vue 3
- Related reading
Vue 3 Composition API, a practical guide
The content here is under the Attribution 4.0 International (CC BY 4.0) license
The Composition API changed how Vue components are written. Instead of spreading a component across data, methods, computed and watch, you group code by what it does, and you can lift any group into a plain function once it grows. This guide covers the parts I use most, with the details that are easy to get wrong. It draws on the Vue 3 Composition API course by Daniel Kelly and the official Vue reactivity documentation.
Why the Composition API exists
Two problems drove its introduction.
- Mixins. Several mixins can define the same key, and when they collide, which one wins is implicit rather than stated. The dependencies a component has on a mixin are also invisible to the reader.
- TypeScript. The Options API types
thispoorly, so editors cannot infer much from it. Reactive state declared indata()loses its type as a component grows.
Both problems come from storing logic in option buckets rather than in functions. The Composition API lets a component declare its own reactive state in plain JavaScript, so the type information survives and the dependencies are explicit.
The setup function and script setup
setup() is the entry point. It runs before the component is created, receives props as the first argument and context as the second, and returns everything the template needs.
There are two ways to write it.
-
<script setup>is the compile-time sugar added in Vue 3.2. Top-level bindings are exposed to the template automatically, so no return statement is needed. - The classic form,
export default { setup(props, context) {} }, requires an explicit return object.
For anything new, use <script setup>. It produces less boilerplate and better TypeScript inference. The classic form remains useful when you need to use a component without a build step.
Reactivity with ref and reactive
Vue offers two ways to create reactive state, and the difference matters.
ref() wraps a value and works with primitives and objects alike. In JavaScript you read and write it through .value. In a template, Vue unwraps it for you. It can be reassigned, and it survives destructuring.
reactive() works only with objects and arrays. It needs no .value, but it cannot be reassigned without losing reactivity, and reading a property through destructuring detaches it from the object.
import { ref, reactive } from 'vue'
const count = ref(0)
count.value++ // primitives need .value in script
const filters = reactive({ search: '', page: 1 })
filters.page = 2 // objects do not
The usual advice is to reach for ref() by default. It is uniform, it keeps working when the value changes from a primitive to an object, and it does not silently break when someone destructures it.
Sharing state between components
ref() and reactive() are created per component call. If two components each call the same composable, they get separate state. To share one piece of state, create it in module scope and export it, or provide it through the tree with provide and inject.
Advanced reactivity utilities
Beyond the two constructors, Vue exports a set of helpers for controlling how much reactivity a value carries.
-
isReactive(),isReadonly(),isProxy()andisRef()test what a value is. -
toRaw()returns the underlying object from a proxy, useful before passing state to a library that should not track it. -
markRaw()marks an object so it is never converted to a proxy. -
shallowReactive()andshallowReadonly()apply reactivity or protection only at the root level, which avoids the cost of deep tracking on large structures.
Derived state with computed
computed() creates a cached value derived from other reactive state. The cache only invalidates when a dependency changes, so the getter is not re-run on every render.
It accepts either a getter function or an object with both get and set.
import { ref, computed } from 'vue'
const first = ref('Ada')
const last = ref('Lovelace')
const fullName = computed(() => `${first.value} ${last.value}`)
A writable computed is useful when a value can legitimately be edited in derived form, for example splitting a full name back into its parts.
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (value) => {
const [f, l] = value.split(' ')
first.value = f
last.value = l
},
})
Watching state changes
watch
watch() observes a specific source and runs a callback when it changes. The source can be a ref, a getter function, an array of sources, or a reactive object.
The callback receives the new value and the previous value. Three options matter in practice.
-
immediate: trueruns the callback once when the watcher is created, which is what you want when the state is already populated. -
deep: truetraverses nested properties, at a cost that grows with the object. -
flushdecides timing, either before the DOM update, after it, or synchronously.
Watching one property of a reactive object requires a getter, because passing the object itself only works with deep.
import { reactive, watch } from 'vue'
const filters = reactive({ search: '', page: 1 })
watch(
() => filters.search,
(next, previous) => {
filters.page = 1
}
)
Every watcher returns a stop handle. Calling it stops the watcher, which matters when you create watchers outside a component or conditionally. The callback also receives an onCleanup function for cancelling work that became obsolete, such as an in-flight request.
watchEffect
watchEffect() runs its callback immediately and tracks every reactive value read inside it. There is no source list and no previous value.
Prefer it when the callback reads everything it needs and you do not care about the previous value. Prefer watch() when you need the old value, want to react only to one source, or want to run the callback lazily.
Reusable logic with composables
A composable is a function that uses Composition API features and returns reactive state. By convention the name starts with use.
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
const update = (event) => {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
Lifecycle hooks such as onMounted and onUnmounted only work while a component is being set up, so a composable must be called from setup() or from another composable, not from an event handler.
This is where the Composition API pays off compared with mixins. A composable states its own dependencies in its signature, cannot collide with another composable by name, and can be composed with others.
Avoiding prop drilling with provide and inject
Passing data through several component layers that do not use it is prop drilling. provide and inject solve it by letting an ancestor offer a value and any descendant claim it.
import { provide, inject, ref } from 'vue'
const themeKey = Symbol('theme')
// ancestor
provide(themeKey, ref('dark'))
// descendant, at any depth
const theme = inject(themeKey, ref('light'))
Two details are worth adopting. Using a Symbol as the key avoids collisions, and providing a readonly() wrapper over the state lets descendants read it while only the provider mutates it.
Async setup and Suspense
A component whose setup() is async must be rendered inside <Suspense>, which shows a fallback while the component resolves.
<template>
<Suspense>
<template #default>
<AsyncProfile />
</template>
<template #fallback>
<p>Loading profile</p>
</template>
</Suspense>
</template>
Suspense is still an experimental feature, so treat its API as subject to change and keep an error boundary around it.
Router composables
Vue Router ships composables that replace the older this.$route and this.$router.
-
useRoute()returns the current route as a reactive object. -
useRouter()returns the router instance for programmatic navigation.
TypeScript support in Vue 3
Vue 3 is written in TypeScript, so the Composition API fits it well. Add lang="ts" to the script block to opt in.
Typing props and events
defineProps and defineEmits are compiler macros, so they are not imported. The type-based form infers the runtime declaration for you.
const props = defineProps<{
title: string
count?: number
}>()
const emit = defineEmits<{
(event: 'create', id: number): void
}>()
withDefaults supplies defaults for the type-based form.
const props = withDefaults(defineProps<{ count?: number }>(), { count: 0 })
Typing reactive state
reactive() and ref() both infer their type from the initial value. Annotate explicitly when the initial value is not representative, for example an empty array that will hold a known type.
Typing provide and inject
A symbol key alone loses its type. InjectionKey restores it, so inject returns the right type instead of unknown.
import type { InjectionKey, Ref } from 'vue'
const themeKey: InjectionKey<Ref<string>> = Symbol('theme')
Related reading
- Vue 3 reactivity API reference, the authoritative list of what each function does.
- Vue 3 Composition API course by Daniel Kelly, which this guide originally drew on.
- Vue.js 3 By Example companion code, for a project-shaped view of the same ideas.
About this post
This post was written by a human with AI assistance, which was used for research, outlining, editing and code suggestions. Claims, code samples and references were reviewed before publication, and the sources are cited so you can judge them yourself.