Please wait while the page loads.
Skip to main content
Vue JS

Vue.js 3 Composition API vs. Options API Guide

Alex Rivers
Jul 31, 2026
11 min read

Introduction: The Architectural Evolution of Vue.js

When Vue.js 3 was released, its most groundbreaking architectural innovation was the introduction of the Composition API. For developers transitioning from Vue 2, this was a significant conceptual shift away from the structured, object-based Options API (data, methods, computed, watch, mounted).

While some developers initially worried that the Options API was being deprecated (it is not, and remains fully supported in Vue 3), the Composition API has quickly become the industry standard for modern, enterprise-grade Vue applications.

In this comprehensive guide, we compare both API paradigms side by side, demonstrate how to refactor an interactive quiz component, explain ref vs. reactive, and explore custom composables for maximum code reusability.


1. Side-by-Side Comparison: Building an Interactive Quiz Card

To understand the core difference between the two paradigms, let's examine how each API builds the exact same interactive countdown quiz component.

The Options API Approach

In the Options API, code is organized strictly by component option type (data, computed, methods, lifecycle hooks):
<!-- QuizCardOptions.vue -->
<template>
  <div class="quiz-card">
    <h3>{{ question.prompt }}</h3>
    <div class="timer">Time Remaining: {{ secondsLeft }}s</div>
    <div class="options">
      <button 
        v-for="opt in question.options" 
        :key="opt.id"
        :class="{ active: selectedId === opt.id }"
        @click="selectOption(opt.id)"
      >
        {{ opt.text }}
      </button>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    question: {
      type: Object,
      required: true
    }
  },
  data() {
    return {
      selectedId: null,
      secondsLeft: 15,
      timerInterval: null
    };
  },
  computed: {
    isExpired() {
      return this.secondsLeft <= 0;
    }
  },
  mounted() {
    this.startTimer();
  },
  beforeUnmount() {
    clearInterval(this.timerInterval);
  },
  methods: {
    selectOption(id) {
      if (this.isExpired) return;
      this.selectedId = id;
      this.$emit('submit', id);
    },
    startTimer() {
      this.timerInterval = setInterval(() => {
        if (this.secondsLeft > 0) {
          this.secondsLeft--;
        } else {
          clearInterval(this.timerInterval);
        }
      }, 1000);
    }
  }
};
</script>

The Composition API with <script setup> Approach

In the Composition API, code is organized by logical feature concern rather than option buckets:
<!-- QuizCardComposition.vue -->
<template>
  <div class="quiz-card">
    <h3>{{ question.prompt }}</h3>
    <div class="timer">Time Remaining: {{ secondsLeft }}s</div>
    <div class="options">
      <button 
        v-for="opt in question.options" 
        :key="opt.id"
        :class="{ active: selectedId === opt.id }"
        @click="selectOption(opt.id)"
      >
        {{ opt.text }}
      </button>
    </div>
  </div>
</template>

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

const props = defineProps({
  question: { type: Object, required: true }
});

const emit = defineEmits(['submit']);

// State
const selectedId = ref(null);
const secondsLeft = ref(15);
let timerInterval = null;

// Computed
const isExpired = computed(() => secondsLeft.value <= 0);

// Methods
function selectOption(id) {
  if (isExpired.value) return;
  selectedId.value = id;
  emit('submit', id);
}

function startTimer() {
  timerInterval = setInterval(() => {
    if (secondsLeft.value > 0) {
      secondsLeft.value--;
    } else {
      clearInterval(timerInterval);
    }
  }, 1000);
}

// Lifecycle
onMounted(() => startTimer());
onBeforeUnmount(() => clearInterval(timerInterval));
</script>

2. Why the Composition API Wins in Scalable Codebases

While both examples achieve the same UI result, the architectural benefits of the Composition API compound dramatically as components grow from 50 lines to 500+ lines:

Options API (Feature fragmentation):
- Feature A code scattered across [data, computed, methods, mounted]
- Feature B code scattered across [data, computed, methods, mounted]

Composition API (Logical co-location):
- Feature A encapsulated together in lines 1-25 (or extracted into useFeatureA())
- Feature B encapsulated together in lines 26-50 (or extracted into useFeatureB())
  1. Logical Co-Location: All state, computed properties, watchers, and lifecycle hooks belonging to a single feature (such as the countdown timer) sit right next to each other.
  2. Effortless Extraction into Composables: You can extract a feature into a standalone JavaScript/TypeScript function with zero refactoring overhead.
  3. Flawless TypeScript Support: The Composition API uses standard JavaScript variables and function calls, providing native type inference without complex this context binding.

3. Creating Custom Composables for Reusable Logic

In the Options API, sharing stateful logic across multiple components required using Mixins, which suffered from namespace collisions, implicit dependencies, and zero TypeScript autocomplete.

In Vue 3, you encapsulate reusable state and logic in Composables (the Vue equivalent of custom React hooks):

// composables/useCountdownTimer.js
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';

export function useCountdownTimer(initialSeconds = 15, onTimeout = null) {
  const secondsLeft = ref(initialSeconds);
  let intervalId = null;

  const isExpired = computed(() => secondsLeft.value <= 0);
  const formattedTime = computed(() => `00:${secondsLeft.value < 10 ? '0' : ''}${secondsLeft.value}`);

  function start() {
    stop();
    secondsLeft.value = initialSeconds;
    intervalId = setInterval(() => {
      if (secondsLeft.value > 0) {
        secondsLeft.value--;
      } else {
        stop();
        if (typeof onTimeout === 'function') onTimeout();
      }
    }, 1000);
  }

  function stop() {
    if (intervalId) {
      clearInterval(intervalId);
      intervalId = null;
    }
  }

  onMounted(() => start());
  onBeforeUnmount(() => stop());

  return {
    secondsLeft,
    formattedTime,
    isExpired,
    start,
    stop,
  };
}

Consuming the Composable Anywhere:

<script setup>
import { useCountdownTimer } from '@/composables/useCountdownTimer';

const { secondsLeft, formattedTime, isExpired } = useCountdownTimer(20, () => {
  console.log('Round timer expired!');
});
</script>

<template>
  <div class="timer-badge">
    <span>{{ formattedTime }}</span>
    <p v-if="isExpired" class="text-red-400">Time's Up!</p>
  </div>
</template>

4. ref() vs. reactive(): Which Should You Use?

One of the most common questions in Vue 3 is deciding between ref() and reactive():

ref() (Recommended Default)

  • Works for all data types: primitives (strings, numbers, booleans) and complex objects/arrays.
  • Requires .value in <script>, but automatically unwraps in <template>.
  • Preserves reactivity when reassigned or passed across composables.
const score = ref(100);
const user = ref({ name: 'Alex', rank: 'Legend' });

// Mutation
score.value += 10;
user.value.rank = 'Mastermind';

reactive()

  • Only works on objects, maps, and sets (cannot hold primitives like reactive(10)).
  • Does not require .value.
  • Warning: Destructuring a reactive object breaks reactivity unless wrapped in toRefs().
import { reactive, toRefs } from 'vue';

const state = reactive({
  score: 100,
  streak: 5,
});

// ❌ Destructuring breaks reactivity:
// const { score, streak } = state;

// ✅ Wrap with toRefs:
const { score, streak } = toRefs(state);

Best Practice: Standardize on ref() across your entire team for consistency and safe composable passing.

Vue 3 Architecture Summary

  • Standardize on <script setup>: Less boilerplate, better runtime performance, and cleaner templates.
  • Replace Mixins with Composables: Build reusable, stateful functions (useAuth, useQuiz) with clear inputs and outputs.
  • Prefer ref() over reactive(): Avoids accidental loss of reactivity during destructuring and composable passing.
  • Options API is Still Valid: Use Options API for simple legacy widgets; choose Composition API for enterprise codebases.

Frequently Asked Questions (FAQ)

Is the Options API being deprecated in Vue 3 or Vue 4?

No. Evan You and the Vue Core Team have repeatedly stated that the Options API will remain fully supported and is a core part of Vue's beginner-friendly philosophy.

Can I mix Options API and Composition API in the same project?

Yes! Vue 3 supports both APIs seamlessly within the same application. You can even declare a setup() hook inside an Options API component.

Why do I have to use .value when accessing a ref in JavaScript?

JavaScript primitives (like numbers and strings) are passed by value, not by reference. Wrapping a primitive in a { value: ... } object wrapper allows Vue's Reactivity Proxy to track reads and intercept writes.

How does the Composition API improve bundle size?

In <script setup>, template code compiles directly into render functions in the same scope, allowing JavaScript bundlers (Vite/Rollup) to aggressively tree-shake unused helper functions and minify variable names.
Alex Rivers

Senior Frontend Architect — Passionate about trivia strategy, speed mechanics, and competitive player rankings.

Keep Reading

Related Guides & Tips

Platform 10 min read

Understanding Monthly Leaderboard & Level Unlocks

A comprehensive breakdown of how monthly points reset, how the dual-ledger leaderboard functions, rank multipliers, and how to unlock exclusive level badges.