Vue Composition API
The Composition API is a set of function-based APIs (ref, reactive, computed, watch, lifecycle hooks) that let you organize component logic by feature instead of by option type. Where the Options API forces related code apart into data, methods, computed, and watch blocks, the Composition API keeps everything about a single concern together — and, crucially, lets you extract that concern into a reusable composable. In real projects this is what makes large Vue components maintainable and lets teams share data-fetching, form, and browser-API logic across dozens of components without mixins or wrapper hell.
- Choose correctly between ref and reactive for state
- Derive read-only state with computed and two-way computed setters
- React to state changes with watch and watchEffect
- Run setup, mount, and cleanup logic with lifecycle hooks
- Extract and reuse stateful logic as composables
<script setup> and reactive state
<script setup> is the recommended compile-time syntax for the Composition API. Top-level bindings — imports, variables, and functions — are exposed directly to the template, and macros like defineProps and defineEmits are available without importing them.
The two primitives for declarative state are ref() and reactive(). A ref wraps a single value (of any type) in a reactive object; you read and write it through .value in JavaScript, but Vue auto-unwraps it in templates. reactive() makes an entire object deeply reactive, with no .value.
vue<script setup lang="ts"> import { ref } from "vue" const count = ref(0) // Ref<number> const user = ref({ name: "Ada" }) function increment() { count.value++ // .value in JS user.value.name = "Grace" // nested access is reactive too } </script> <template> <!-- auto-unwrapped: no .value in templates --> <button @click="increment">{{ count }} — {{ user.name }}</button> </template>
Pro Tip
Default to ref for almost everything. It works with primitives, objects, and arrays, survives reassignment (data.value = newArray), and has one consistent access rule (.value). Reach for reactive only when you want an object-shaped store with no .value noise.
Keeping reactivity when you destructure
A reactive object loses its reactive connection the moment you destructure it, because you copy the plain value out of the proxy. Use toRefs to convert each property back into a linked ref.
tsimport { reactive, toRefs } from "vue" const state = reactive({ x: 0, y: 0 }) // ❌ x and y are plain numbers now — updates to state won't reflect const { x, y } = state // ✅ each stays connected to state const { x: rx, y: ry } = toRefs(state) rx.value++ // also updates state.x
Computed properties
computed() creates a cached, read-only reactive value derived from other reactive sources. It only re-evaluates when one of its dependencies changes — unlike a method, which re-runs on every render. Pass a getter for read-only values, or an object with get and set for a writable computed.
tsimport { ref, computed } from "vue" const firstName = ref("Ada") const lastName = ref("Lovelace") // read-only computed (cached) const fullName = computed(() => `${firstName.value} ${lastName.value}`) // writable computed const fullNameEditable = computed({ get: () => `${firstName.value} ${lastName.value}`, set: (value: string) => { [firstName.value, lastName.value] = value.split(" ") }, }) fullNameEditable.value = "Grace Hopper" // updates both refs
Warning
Never mutate state inside a computed getter or perform async work there. A getter must be a pure function of its dependencies — side effects belong in watch, watchEffect, or event handlers. A computed that mutates other state leads to unpredictable, hard-to-trace updates.
Watchers: watch vs watchEffect
Watchers run side effects — logging, fetching, saving to localStorage — in response to reactive changes. watch is explicit about its source and gives you both old and new values. watchEffect runs immediately and automatically tracks whatever reactive values it reads.
tsimport { ref, watch } from "vue" const query = ref("") const results = ref<string[]>([]) // watch a ref: get new and old values watch(query, async (newVal, oldVal) => { if (newVal.trim() === "") return const res = await fetch(`/api/search?q=${newVal}`) results.value = await res.json() }) // watch a getter, run once on setup, deep-compare nested objects watch( () => filters.value.category, (cat) => console.log("category changed:", cat), { immediate: true, deep: true } )
Note
Use watchEffect when the effect depends on several sources and you want it to run right away. Use watch when you need the previous value, want it lazy by default, or must watch a source you don't read inside the callback. The onCleanup callback lets you cancel in-flight work before the next run to avoid race conditions.
Lifecycle hooks
Composition-API lifecycle hooks are registered by calling functions like onMounted during setup. They run at the same points as their Options-API equivalents, and you can register several of the same hook. Use onMounted for DOM access or subscriptions, and onUnmounted to clean them up.
tsimport { ref, onMounted, onUnmounted } from "vue" const width = ref(window.innerWidth) function onResize() { width.value = window.innerWidth } onMounted(() => { window.addEventListener("resize", onResize) }) onUnmounted(() => { // always remove listeners you add, or you leak memory window.removeEventListener("resize", onResize) })
Composables: reusable stateful logic
A composable is a plain function, conventionally named useSomething, that uses Composition-API functions to encapsulate and reuse stateful logic. Each call creates its own isolated reactive state, so unlike mixins there are no naming collisions or unclear data sources.
ts// composables/useFetch.ts import { ref, watchEffect, toValue, type Ref } from "vue" export function useFetch<T>(url: Ref<string> | (() => string)) { const data = ref<T | null>(null) const error = ref<Error | null>(null) const loading = ref(false) watchEffect(async (onCleanup) => { const controller = new AbortController() onCleanup(() => controller.abort()) loading.value = true error.value = null try { // toValue() unwraps a ref OR calls a getter → always a string const res = await fetch(toValue(url), { signal: controller.signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) data.value = (await res.json()) as T } catch (e) { if ((e as Error).name !== "AbortError") error.value = e as Error } finally { loading.value = false } }) return { data, error, loading } }
Consuming it in a component stays tiny and declarative:
vue<script setup lang="ts"> import { ref, computed } from "vue" import { useFetch } from "@/composables/useFetch" const id = ref(1) const url = computed(() => `/api/posts/${id.value}`) const { data, error, loading } = useFetch<{ title: string }>(url) </script> <template> <p v-if="loading">Loading…</p> <p v-else-if="error">Failed: {{ error.message }}</p> <h1 v-else>{{ data?.title }}</h1> <button @click="id++">Next post</button> </template>
Pro Tip
Accept refs or getters as arguments and unwrap them with toValue(). This makes composables "reactive by default" — the caller can pass a computed URL and the composable automatically re-runs when it changes, without any extra wiring.
Common Pitfalls
- Forgetting
.value— in JavaScript a ref is always accessed via.value.count + 1concatenates the ref object; you meantcount.value + 1. (Templates auto-unwrap, JS does not.) - Destructuring a
reactiveobject breaks reactivity. UsetoRefs/toRef, or just userefinstead. - Reassigning a
reactivevalue (state = reactive(...)) drops the original proxy. Mutate its properties, or use arefif you need whole-value replacement. - Registering lifecycle hooks asynchronously —
onMountedmust be called synchronously during setup, not inside asetTimeoutor after anawait. - Side effects in
computed— getters must be pure. Fetch and mutate in watchers or handlers instead.
Best Practices
- Prefer
refas your default and reservereactivefor cohesive object stores. - Keep computeds pure and lean; move async and side-effecting logic into
watch/watchEffect. - Always clean up timers, listeners, and subscriptions in
onUnmounted(or viaonCleanup). - Extract composables the moment logic is reused or a component's
<script>grows past one concern — name themuseXand return refs so callers keep reactivity. - Type your refs with generics (
ref<User[]>([]),useFetch<Post>()) so the compiler catches shape mistakes early.
Next Steps
Next Lesson
Pinia State Management