Skip to content

Vue 3 Composition API Reference: ref, computed, Composables, Props, Pinia & Vue Router

Vue 3 Composition API is the recommended way to write Vue 3 components. It replaces the Options API’s scattered data/methods/computed/watch with collocated ref, computed, and watch calls — and makes it straightforward to extract reusable logic into composables.

1. ref, reactive, computed and watch

Core reactivity primitives and when to use ref vs reactive
<script setup lang="ts">
import { ref, reactive, computed, watch, watchEffect } from "vue";

// ref — reactive primitive (access with .value in script):
const count = ref(0);
const name = ref("Alice");
count.value++;                               // mutation in script
// In template: {{ count }}  (no .value needed)

// reactive — reactive object (no .value needed):
const user = reactive({ name: "Alice", age: 30 });
user.name = "Bob";                           // direct mutation
// Gotcha: don't destructure reactive — it loses reactivity:
// const { name } = user;  BAD — name is no longer reactive
// Use toRefs to destructure safely:
// const { name, age } = toRefs(user);

// ref vs reactive:
// ref for primitives (string, number, boolean) and single values
// reactive for objects where you want to mutate properties directly
// Both work for objects — pick one and be consistent

// computed (derived state — auto-recalculates):
const doubled = computed(() => count.value * 2);
const fullName = computed(() => `${user.name} (${user.age})`);
// computed.value is readonly by default

// Writable computed:
const fullNameEditable = computed({
  get: () => `${user.firstName} ${user.lastName}`,
  set: (value) => {
    [user.firstName, user.lastName] = value.split(" ");
  }
});

// watch (run side effects when reactive data changes):
watch(count, (newVal, oldVal) => {
  console.log(`count: ${oldVal} -> ${newVal}`);
});
watch(() => user.name, (newName) => console.log("name changed:", newName));
watch([count, () => user.name], ([newCount, newName]) => {
  // watch multiple sources
});

// watchEffect (auto-tracks dependencies — runs immediately):
watchEffect(() => {
  console.log("count is", count.value);   // re-runs when count changes
  document.title = `Count: ${count.value}`;
});
</script>

2. Template Syntax & Directives

v-if, v-for, v-model, v-bind, v-on and the key directive
<template>
  <!-- Text interpolation: -->
  <p>{{ message }}</p>

  <!-- v-bind (dynamic attributes): -->
  <img :src="imageUrl" :alt="imageAlt" />
  <button :disabled="isLoading" :class="{ active: isActive, error: hasError }">Save</button>
  <div :style="{ color: textColor, fontSize: fontSize + 'px' }"></div>

  <!-- v-on (event handling): -->
  <button @click="handleClick">Click</button>
  <button @click="count++">Inline</button>
  <input @keydown.enter="submitForm" @keydown.esc="cancelForm" />

  <!-- v-model (two-way binding): -->
  <input v-model="searchQuery" />
  <input v-model.trim="name" v-model.number="age" />    <!-- modifiers -->
  <input type="checkbox" v-model="isChecked" />
  <select v-model="selectedOption">
    <option value="a">Option A</option>
  </select>

  <!-- v-if / v-else-if / v-else: -->
  <div v-if="status === 'loading'">Loading...</div>
  <div v-else-if="status === 'error'">Error!</div>
  <div v-else>{{ data }}</div>

  <!-- v-show (CSS display vs DOM removal): -->
  <div v-show="isVisible">Toggled with CSS</div>

  <!-- v-for (always use :key): -->
  <ul>
    <li v-for="user in users" :key="user.id">
      {{ user.name }}
    </li>
  </ul>

  <!-- v-for with index: -->
  <div v-for="(item, index) in items" :key="item.id">
    {{ index }}: {{ item.name }}
  </div>
</template>

3. Composables — Reusable Logic

Extract stateful logic into composable functions (Vue’s equivalent of React hooks)
// composables/useFetch.ts — reusable data fetching:
import { ref, watch } from "vue";

export function useFetch<T>(url: string | (() => string)) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const isLoading = ref(false);

  async function fetchData() {
    isLoading.value = true;
    error.value = null;
    try {
      const res = await fetch(typeof url === "function" ? url() : url);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      data.value = await res.json();
    } catch (e) {
      error.value = e as Error;
    } finally {
      isLoading.value = false;
    }
  }

  watch(() => (typeof url === "function" ? url() : url), fetchData, { immediate: true });

  return { data, error, isLoading, refetch: fetchData };
}

// composables/useLocalStorage.ts:
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key);
  const value = ref<T>(stored ? JSON.parse(stored) : defaultValue);

  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal));
  }, { deep: true });

  return value;
}

// Usage in a component:
<script setup lang="ts">
import { useFetch } from "@/composables/useFetch";
import { useLocalStorage } from "@/composables/useLocalStorage";

const { data: users, isLoading, error } = useFetch<User[]>("/api/users");
const theme = useLocalStorage("theme", "light");
</script>

4. Props, Emits & Component Patterns

defineProps, defineEmits, defineExpose, provide/inject, and slots
// Child component:
<script setup lang="ts">
const props = defineProps<{
  title: string;
  count: number;
  items?: string[];             // optional
}>();

// With defaults:
const props2 = withDefaults(defineProps<{ size?: "sm" | "md" | "lg" }>(), {
  size: "md"
});

const emit = defineEmits<{
  update: [value: string];     // typed event with payload
  close: [];                   // event with no payload
}>();

// Emit an event:
function handleSubmit(value: string) {
  emit("update", value);
}

// Expose methods to parent (for template refs):
defineExpose({ focus: () => inputRef.value?.focus() });
</script>

// Parent component:
<template>
  <ChildComponent
    :title="pageTitle"
    :count="itemCount"
    @update="handleUpdate"
    @close="isOpen = false"
  />
</template>

// Provide/Inject (avoid prop drilling):
// In parent:
import { provide } from "vue";
provide("theme", ref("dark"));
provide("user", readonly(currentUser));   // readonly prevents child mutation

// In any descendant:
import { inject } from "vue";
const theme = inject("theme", ref("light"));   // second arg = default

// Slot:
// Child:
<template>
  <div class="card">
    <slot name="header" :title="title" />    <!-- named slot with slot props -->
    <slot />                                  <!-- default slot -->
  </div>
</template>
// Parent:
<Card>
  <template #header="{ title }"><h2>{{ title }}</h2></template>
  <p>Card content</p>
</Card>

5. Vue Router & Pinia (State Management)

Route params, navigation guards, Pinia stores, and storeToRefs
// Vue Router 4:
import { useRouter, useRoute } from "vue-router";

const router = useRouter();
const route = useRoute();

// Route params and query:
const userId = route.params.id as string;     // /users/:id
const page = route.query.page as string;       // ?page=2

// Programmatic navigation:
router.push("/users");
router.push({ name: "user-detail", params: { id: "123" } });
router.push({ path: "/users", query: { page: "2" } });
router.replace("/login");                      // replace history entry
router.back();

// Navigation guard (auth check):
router.beforeEach((to, from) => {
  const authStore = useAuthStore();
  if (to.meta.requiresAuth && !authStore.isLoggedIn) {
    return { name: "login", query: { redirect: to.fullPath } };
  }
});

// Route meta:
// { path: "/admin", component: Admin, meta: { requiresAuth: true, role: "admin" } }

// Pinia (state management):
import { defineStore } from "pinia";

export const useCartStore = defineStore("cart", () => {
  const items = ref<CartItem[]>([]);
  const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0));

  function addItem(item: CartItem) {
    const existing = items.value.find(i => i.id === item.id);
    if (existing) existing.qty++;
    else items.value.push({ ...item, qty: 1 });
  }

  function removeItem(id: string) {
    items.value = items.value.filter(i => i.id !== id);
  }

  return { items, total, addItem, removeItem };
});

// In component:
import { storeToRefs } from "pinia";
const cart = useCartStore();
const { items, total } = storeToRefs(cart);   // reactive refs from store
cart.addItem(product);                         // call action directly

Track Vue.js and frontend framework releases at ReleaseRun. Related: React Reference | TypeScript Reference | Next.js Reference

🔍 Free tool: npm Package Health Checker — check Vue.js packages — Vuex, Pinia, Vue Router — for known CVEs and active maintenance before upgrading.

Founded

2023 in London, UK

Contact

hello@releaserun.com