intermediate
frontend

Composition API

ref, reactive, computed, watch, and composables for reusable logic

vue fundamentals
28 min read

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.

Learning Objectives
  • 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>

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.

ts
import { 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.

ts
import { 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

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.

ts
import { 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 } )

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.

ts
import { 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>

Common Pitfalls

Mistakes to avoid
  • Forgetting .value — in JavaScript a ref is always accessed via .value. count + 1 concatenates the ref object; you meant count.value + 1. (Templates auto-unwrap, JS does not.)
  • Destructuring a reactive object breaks reactivity. Use toRefs / toRef, or just use ref instead.
  • Reassigning a reactive value (state = reactive(...)) drops the original proxy. Mutate its properties, or use a ref if you need whole-value replacement.
  • Registering lifecycle hooks asynchronously — onMounted must be called synchronously during setup, not inside a setTimeout or after an await.
  • Side effects in computed — getters must be pure. Fetch and mutate in watchers or handlers instead.

Best Practices

  • Prefer ref as your default and reserve reactive for 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 via onCleanup).
  • Extract composables the moment logic is reused or a component's <script> grows past one concern — name them useX and return refs so callers keep reactivity.
  • Type your refs with generics (ref<User[]>([]), useFetch<Post>()) so the compiler catches shape mistakes early.

Next Steps

Continue Learning
Global stores with Pinia.

Next Lesson

Pinia State Management