Mastering Vue 3 Composition API: The Ultimate Guide for Modern Web Development

Introduction: Why the Composition API Changes Everything

If you have been in the Vue.js ecosystem for a while, you probably remember the “Options API.” It was the standard way we built components in Vue 2—organizing code by data, methods, computed, and mounted. While this was intuitive for small projects, it presented a massive challenge as applications scaled: logic became fragmented. A single feature (like a search bar) would have its state in data, its logic in methods, and its event listeners in mounted.

The Vue 3 Composition API was introduced to solve this “God Object” problem. Instead of organizing code by options, we now organize code by logical concerns. This shift doesn’t just make your code cleaner; it unlocks superior TypeScript support, better minification, and the ability to create highly reusable “Composables.”

In this guide, we are going to dive deep into every corner of the Composition API. Whether you are a beginner looking to understand reactivity or an expert wanting to architect complex systems, this post will provide the roadmap you need to master Vue 3.

The Core Concept: setup() and <script setup>

The setup() function is the entry point for the Composition API. However, in modern Vue development, we use the <script setup> syntax. This is a compile-time transform that allows us to write less boilerplate code.

Consider the difference. In the traditional way, you had to return every variable and function to the template. With <script setup>, any top-level variable is automatically available to the template.


<script setup>
// Variables defined here are automatically available in the template
const message = "Hello Vue 3!";

const greet = () => {
  alert(message);
};
</script>

<template>
  <button @click="greet">{{ message }}</button>
</template>
            

Understanding Reactivity: ref vs. reactive

Reactivity is the heartbeat of Vue. It is what allows the DOM to update automatically when a variable changes. In the Composition API, we primarily use two functions to create reactive state: ref and reactive.

1. The ref() Function

ref is used for primitive types (strings, numbers, booleans) and can also handle objects. When you use ref, Vue wraps the value in an object with a single .value property. This is necessary because JavaScript primitives are passed by value, not by reference.


import { ref } from 'vue';

// Initialize a reactive number
const count = ref(0);

// To update or read the value in JavaScript, use .value
const increment = () => {
  count.value++;
};

// Note: In the <template>, Vue automatically unwraps refs, 
// so you don't need .value there.
            

2. The reactive() Function

reactive is specifically for objects and arrays. It makes the object itself reactive. Unlike ref, you don’t need .value to access properties. However, it has a significant drawback: you cannot destructure a reactive object without losing reactivity.


import { reactive } from 'vue';

const state = reactive({
  user: 'John Doe',
  points: 100
});

// Update directly
state.points += 10;
            

When to use which?

  • Use ref: For almost everything. It is safer, more explicit, and works with all data types. Most modern Vue teams prefer ref as the default.
  • Use reactive: When you have a complex state object that is tightly coupled and you want to avoid .value. Just remember to use toRefs if you need to destructure it.

Deep Dive: Computed Properties

Computed properties are cached based on their reactive dependencies. They only re-evaluate when one of their dependencies changes. This is vital for performance when dealing with expensive calculations.


import { ref, computed } from 'vue';

const firstName = ref('Jane');
const lastName = ref('Smith');

// Fullname will update only when firstName or lastName changes
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`;
});
            

Real-world Example: Imagine a shopping cart. You have an array of items. You can use a computed property to calculate the total price. As users add or remove items, the total updates instantly, but it doesn’t recalculate if you change the user’s profile name on the same page.

Watchers: Responding to Changes

Sometimes we need to perform “side effects” when data changes—like making an API call, saving to localStorage, or triggering an animation. This is where watch and watchEffect come in.

watch

watch is lazy. It only fires when the specific source you are watching changes. It also provides both the new and old values.


import { ref, watch } from 'vue';

const searchInput = ref('');

watch(searchInput, (newValue, oldValue) => {
  console.log(`User searched for: ${newValue}`);
  // Trigger API call here
});
            

watchEffect

watchEffect runs immediately and automatically tracks every reactive property used inside its body. It is more concise but gives you less control over what specifically triggers the effect.


import { ref, watchEffect } from 'vue';

const count = ref(0);

// This runs immediately, and then every time count changes
watchEffect(() => {
  console.log(`The count is now: ${count.value}`);
});
            

The Power of Composables

The “Killer Feature” of the Composition API is the Composable. A composable is a function that leverages Vue’s reactivity to encapsulate and reuse logic. This replaces the old “Mixins” pattern, which often led to naming collisions and “mystery” variables.

Creating a useFetch Composable

Let’s create a real-world utility to handle API requests. This logic can be reused in any component across your app.


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

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

  const fetchData = async () => {
    loading.value = true;
    try {
      const response = await fetch(url);
      data.value = await response.json();
    } catch (err) {
      error.value = err;
    } finally {
      loading.value = false;
    }
  };

  fetchData();

  return { data, error, loading };
}
            

Using the Composable in a Component


<script setup>
import { useFetch } from './composables/useFetch';

const { data, error, loading } = useFetch('https://api.example.com/products');
</script>

<template>
  <div v-if="loading">Loading products...</div>
  <div v-else-if="error">Error: {{ error.message }}</div>
  <ul v-else>
    <li v-for="item in data" :key="item.id">{{ item.name }}</li>
  </ul>
</template>
            

Lifecycle Hooks in Composition API

Lifecycle hooks work similarly to the Options API but are imported as functions. Note that beforeCreate and created are not needed because the setup() function itself acts as these hooks.

  • onMounted(): Called after the component is added to the DOM.
  • onUpdated(): Called after a reactive state change and DOM update.
  • onUnmounted(): Called before the component is destroyed. Excellent for cleaning up timers or event listeners.

import { onMounted, onUnmounted } from 'vue';

onMounted(() => {
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
});
            

Common Mistakes and How to Fix Them

1. Losing Reactivity During Destructuring

The Mistake: Trying to destructure a reactive object like a standard JS object.


const state = reactive({ count: 0 });
const { count } = state; // 'count' is now just a plain number, not reactive!
            

The Fix: Use toRefs to maintain the reactive connection.


import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0 });
const { count } = toRefs(state); // 'count' is now a reactive ref.
            

2. Forgetting .value in JavaScript

The Mistake: Trying to change a ref’s value directly.


const count = ref(0);
count = 5; // Error! You are overwriting the ref object.
            

The Fix: Always use .value when modifying or reading refs in your script tags.

3. Overusing reactive() for everything

The Mistake: Using reactive for single primitive values.

The Fix: Stick to ref for single values. It makes it much easier to track which variables are reactive throughout your code.

Step-by-Step: Building a Reactive Todo App

Let’s put everything together into a small, functional project. This app will allow users to add tasks, toggle completion, and filter tasks.

Step 1: Setup the State

We use a ref for the new task input and another ref for the list of todos.


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

const newTodo = ref('');
const todos = ref([]);

const addTodo = () => {
  if (newTodo.value.trim()) {
    todos.value.push({
      id: Date.now(),
      text: newTodo.value,
      completed: false
    });
    newTodo.value = '';
  }
};
</script>
            

Step 2: Add Logic for Toggling and Deleting


const toggleTodo = (id) => {
  const todo = todos.value.find(t => t.id === id);
  if (todo) todo.completed = !todo.completed;
};

const removeTodo = (id) => {
  todos.value = todos.value.filter(t => t.id !== id);
};
            

Step 3: Add a Computed Filter


const pendingTasks = computed(() => {
  return todos.value.filter(t => !t.completed).length;
});
            

Step 4: The Template


<template>
  <div class="todo-app">
    <input v-model="newTodo" @keyup.enter="addTodo" placeholder="Add a task">
    <p>You have {{ pendingTasks }} tasks remaining.</p>
    
    <ul>
      <li v-for="todo in todos" :key="todo.id">
        <span :class="{ done: todo.completed }" @click="toggleTodo(todo.id)">
          {{ todo.text }}
        </span>
        <button @click="removeTodo(todo.id)">Delete</button>
      </li>
    </ul>
  </div>
</template>
            

Advanced Patterns: Provide and Inject

When you have deeply nested components, passing “props” down five levels is painful (Prop Drilling). The Composition API provides provide and inject to share state across entire component trees.


// ParentComponent.vue
import { provide, ref } from 'vue';

const theme = ref('dark');
provide('app-theme', theme);

// DeeplyNestedChild.vue
import { inject } from 'vue';

const currentTheme = inject('app-theme');
            

Performance Optimization in Vue 3

Vue 3’s Composition API is naturally faster than Vue 2, but we can push it further:

  • ShallowRef: If you have a huge object and you don’t need its internal properties to be reactive, use shallowRef. Vue will only watch the .value itself, not the nested children.
  • MarkRaw: Use markRaw to prevent a specific object from ever becoming reactive. This is great for large third-party library instances like MapBox or Chart.js.
  • Lazy Loading: Combine Composition API with defineAsyncComponent to split your code and reduce initial load times.

Summary / Key Takeaways

  • Composition API organizes code by logic, not by options.
  • <script setup> is the modern standard for writing Vue components.
  • ref is preferred for most state, while reactive is for objects.
  • Composables are the best way to share reusable logic across components.
  • Always use .value in scripts for refs, but omit it in templates.
  • Use computed for derived state and watch for side effects.

Frequently Asked Questions (FAQ)

1. Is the Options API being deprecated?

No. The Options API is still supported in Vue 3 and there are no immediate plans to remove it. However, the Composition API is recommended for larger, more complex projects and for better TypeScript integration.

2. Can I use both Options API and Composition API in the same component?

Technically, yes. You can use the setup() function alongside options like data or methods. However, this is generally discouraged as it makes the component harder to read and maintain. Pick one style per component.

3. Does using Composition API make my bundle larger?

Actually, it usually makes it smaller! Because the Composition API uses plain variables and functions, it is much easier for build tools like Vite and Webpack to “minify” and “tree-shake” your code compared to the Options API object.

4. Why do I need to use .value with refs?

JavaScript doesn’t have “reactive” primitives. When you change a string or a number, the reference is lost. Vue wraps these in an object (the ref) so it can track when that object’s value changes using a property setter. The .value is simply the name of that property.