Composition API

Organizing logic with composables, plus computed() and watch()/watchEffect().

Why the Composition API exists

Vue's older Options API organizes a component by kind of code — all data() together, all methods together, all computed properties together. That works fine for small components, but as a component grows, logic for one single feature ends up scattered across several different sections of the file. The Composition API (what <script setup> compiles down to) instead organizes code by feature — everything related to one concern can live together, and, crucially, that logic can be extracted into a reusable function entirely independent of any specific component.

computed(): derived reactive values

A computed value is derived from other reactive state, automatically recalculating only when one of its dependencies actually changes — and caching its result in between:

HTML
<script setup>
import { ref, computed } from "vue";

const price = ref(100);
const quantity = ref(3);

const total = computed(() => price.value * quantity.value);
</script>

<template>
  <p>Total: ${{ total }}</p>
</template>

total recalculates only when price or quantity changes — not on every re-render, and not when unrelated state elsewhere in the component changes. This caching is exactly what distinguishes a computed() from just calling a plain function in the template: a plain function re-runs on every render regardless of whether its inputs changed.

watch(): reacting to a specific change

watch() runs a callback when one or more specific reactive sources change — useful for side effects like an API call, not for deriving a new value (that's what computed() is for):

HTML
<script setup>
import { ref, watch } from "vue";

const searchQuery = ref("");
const results = ref([]);

watch(searchQuery, async (newQuery, oldQuery) => {
  if (newQuery.trim() === "") {
    results.value = [];
    return;
  }
  const response = await fetch(`/api/search?q=${newQuery}`);
  results.value = await response.json();
});
</script>

<template>
  <input v-model="searchQuery" placeholder="Search..." />
  <ul>
    <li v-for="result in results" :key="result.id">{{ result.name }}</li>
  </ul>
</template>

The callback receives both the new and old value, and watch only fires when the watched source actually changes — it does not run immediately on setup unless you explicitly pass { immediate: true }.

watchEffect(): automatic dependency tracking

watchEffect() runs its callback immediately, and automatically tracks whatever reactive values it reads inside — no explicit source argument needed:

Javascript
import { ref, watchEffect } from "vue";

const userId = ref(1);
const user = ref(null);

watchEffect(async () => {
  user.value = await fetch(`/api/users/${userId.value}`).then(r => r.json());
});
// re-runs automatically whenever userId.value changes,
// because the callback read it during its last run

Use watch() when you need to compare old vs. new values, control exactly which sources trigger it, or skip the initial run. Use watchEffect() for a simpler "just re-run this whenever anything it reads changes" side effect, especially when it depends on several reactive values at once and listing them all explicitly would be redundant.

Composables: extracting reusable logic

A composable is just a function that uses Composition API primitives (ref, computed, watch, etc.) and encapsulates some reusable stateful logic — by convention, named starting with use. This is the Composition API's answer to a problem Vue's Options API and React's older patterns both struggled with: sharing stateful logic between components without deeply nesting wrapper components.

Javascript
// composables/useCounter.js
import { ref, computed } from "vue";

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const isEven = computed(() => count.value % 2 === 0);

  function increment() {
    count.value++;
  }

  function decrement() {
    count.value--;
  }

  function reset() {
    count.value = initialValue;
  }

  return { count, isEven, increment, decrement, reset };
}
HTML
<!-- CounterWidget.vue -->
<script setup>
import { useCounter } from "@/composables/useCounter";

const { count, isEven, increment, decrement, reset } = useCounter(10);
</script>

<template>
  <div>
    <p>{{ count }} ({{ isEven ? "even" : "odd" }})</p>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>
    <button @click="reset">Reset</button>
  </div>
</template>

Any component that needs a counter can call useCounter() and get its own fully independent instance of that state and behavior — the same logic reused with zero copy-pasting. A more realistic example is a useFetch composable wrapping the loading/error/data pattern:

Javascript
// composables/useFetch.js
import { ref, watchEffect } from "vue";

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);

  watchEffect(async () => {
    loading.value = true;
    error.value = null;
    try {
      const response = await fetch(url.value ?? url);
      if (!response.ok) throw new Error(`Request failed: ${response.status}`);
      data.value = await response.json();
    } catch (err) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  });

  return { data, error, loading };
}
HTML
<script setup>
import { useFetch } from "@/composables/useFetch";

const { data: user, error, loading } = useFetch("/api/users/1");
</script>

<template>
  <p v-if="loading">Loading...</p>
  <p v-else-if="error">Error: {{ error }}</p>
  <div v-else>
    <h2>{{ user.name }}</h2>
  </div>
</template>

Any component that needs to fetch something now writes one line, useFetch(url), instead of re-implementing the loading/error/data dance every time.

Common mistakes

  • Using computed() for something that should have a side effect (like an API call) — computed properties are meant to be pure derivations and should not have side effects; use watch() or watchEffect() for side effects instead.
  • Forgetting that a computed() is read-only by default — assigning to total.value directly throws a warning unless you explicitly define a setter.
  • Naming a composable without the use prefix — purely a convention, not enforced by Vue, but breaking it makes code much harder for other developers (and linters) to recognize as composable logic.
  • Returning a plain destructured value from a composable instead of a ref/computed, which silently loses reactivity for the consuming component.