Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Composition vs Options API

Two ways to write components and when to use each.

Vue gives you two ways to write a component's logic: the Options API (the original style, organised into data, methods, computed and so on) and the Composition API (functions such as ref, computed and watch called inside setup). Both sit on the same reactivity system and produce the same component. They differ in how you organise code.

This lesson writes the same Task board component both ways, explains what <script setup> compiles to, and gives you a clear answer for "which one should we use?"

The same component, two ways

Here is a small task list with an input, an "add" action, a derived count and a filter. First, the Options API:

<script>
export default {
    data() {
        return {
            newTitle: '',
            hideDone: false,
            tasks: [
                { id: 1, title: 'Design schema', done: true },
                { id: 2, title: 'Build API', done: false },
            ],
        };
    },
    computed: {
        remaining() {
            return this.tasks.filter(t => !t.done).length;
        },
        visibleTasks() {
            return this.hideDone ? this.tasks.filter(t => !t.done) : this.tasks;
        },
    },
    methods: {
        addTask() {
            if (!this.newTitle.trim()) return;
            this.tasks.push({ id: Date.now(), title: this.newTitle, done: false });
            this.newTitle = '';
        },
    },
    mounted() {
        console.log('Task board mounted');
    },
};
</script>

Now the Composition API with <script setup>:

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

const newTitle = ref('');
const hideDone = ref(false);
const tasks = ref([
    { id: 1, title: 'Design schema', done: true },
    { id: 2, title: 'Build API', done: false },
]);

const remaining = computed(() => tasks.value.filter(t => !t.done).length);
const visibleTasks = computed(() =>
    hideDone.value ? tasks.value.filter(t => !t.done) : tasks.value
);

function addTask() {
    if (!newTitle.value.trim()) return;
    tasks.value.push({ id: Date.now(), title: newTitle.value, done: false });
    newTitle.value = '';
}

onMounted(() => console.log('Task board mounted'));
</script>

Both share one template:

<template>
    <form @submit.prevent="addTask">
        <input v-model="newTitle" placeholder="New task…">
        <button>Add</button>
    </form>
    <label><input type="checkbox" v-model="hideDone"> Hide completed</label>
    <ul>
        <li v-for="task in visibleTasks" :key="task.id">
            <input type="checkbox" v-model="task.done"> {{ task.title }}
        </li>
    </ul>
    <p>{{ remaining }} remaining</p>
</template>

Below, the two versions run side by side using exactly this code and this shared template. They are separate component instances with separate state, one defined with options and one with setup(). Open the browser console to see each one's mount hook log Task board mounted:

How the code is organised

The real difference shows up as a component grows. The Options API groups code by option type: all data together, all methods together. The Composition API lets you group code by logical concern:

Options API: grouped by type Composition API: grouped by feature data() computed methods mounted() Task list + filtering Sync with server Keyboard shortcuts Same colour = same feature. Each feature block on the right can be moved into a composable.

With the Options API, adding "sync tasks to the server" means touching data, methods and mounted, and the code for one feature ends up scattered. With the Composition API, the whole feature sits in one block of code. More importantly, you can lift that block into a function and reuse it:

// composables/useTaskFilter.js
import { ref, computed } from 'vue';

export function useTaskFilter(tasks) {
    const hideDone = ref(false);
    const visibleTasks = computed(() =>
        hideDone.value ? tasks.value.filter(t => !t.done) : tasks.value
    );
    return { hideDone, visibleTasks };
}
<script setup>
import { ref } from 'vue';
import { useTaskFilter } from '@/composables/useTaskFilter';

const tasks = ref([]);
const { hideDone, visibleTasks } = useTaskFilter(tasks);
</script>

Here is useTaskFilter running for real. Two different lists, tasks and bugs, each get their own independent filter by calling the same function:

These reusable functions are called composables, and they get their own lesson in Composables. In the Options API the equivalent was mixins, which had real problems: unclear where a property came from, name collisions, and implicit coupling between mixins.

What <script setup> actually is

<script setup> is compile-time syntactic sugar. The SFC compiler turns this:

<script setup>
import { ref } from 'vue';
const count = ref(0);
function increment() { count.value++; }
</script>

into roughly this:

// Compiled output. The Composition API board in the demo above is written in this shape.
import { ref } from 'vue';

export default {
    setup() {
        const count = ref(0);
        function increment() { count.value++; }
        // every top-level binding is exposed to the template
        return { count, increment };
    },
};

That's why top-level variables, functions and imports are automatically available in the template. You never write the return. It also gives you compiler macros that need no import:

Macro Purpose
defineProps() Declare props
defineEmits() Declare emitted events
defineModel() Two-way binding prop (Vue 3.4+)
defineExpose() Choose what a parent can access via a template ref
defineOptions() Set name, inheritAttrs etc. (Vue 3.3+)
defineSlots() Type slots (TypeScript)

<script setup> components are closed by default. A parent holding a template ref to the component can't reach its internals unless you call defineExpose({ … }). With the Options API, everything on this was reachable.

this vs explicit references

The Options API relies on this, a proxy that merges data, props, computed and methods into one object. The Composition API has no this. You work with plain variables and functions:

Concern Options API Composition API
State data() { return { n: 0 } } → this.n const n = ref(0) → n.value
Derived computed: { double() { … } } const double = computed(() => …)
Methods methods: { inc() { this.n++ } } function inc() { n.value++ }
Watch watch: { n(val) { … } } watch(n, val => …)
Lifecycle mounted() { … } onMounted(() => …)
Props props: ['title'] → this.title const props = defineProps(['title'])
Emit this.$emit('save') const emit = defineEmits(['save'])

No this brings practical benefits:

  • Type inference just works. Variables have real types, so TypeScript doesn't need to infer the shape of a merged this.
  • Better minification. Local variable names can be shortened; this.someLongName property names can't.
  • No arrow-function traps. In the Options API, methods: { save: () => this.x } silently breaks because arrow functions don't bind this.

The .value question

The trade-off is .value. A ref is an object wrapper, so in JavaScript you read and write count.value. In templates refs are auto-unwrapped, so you write {{ count }}. Many teams treat .value as a helpful signal: "this is reactive state".

A note on TypeScript

The Composition API was designed with TypeScript in mind. Because everything is a plain variable or function rather than a property merged onto this, types flow through without extra annotations, and <script setup lang="ts"> lets you declare props and emits with pure types (defineProps<{ initialTasks: Task[] }>()). TypeScript with Vue covers it properly.

Which one should you use?

Which API to use. The Composition API with <script setup> is the recommended default for new Vue 3 projects. It scales better as components grow, enables logic reuse through composables, and has first-class TypeScript support. The Options API is still fully supported, isn't deprecated, and is perfectly readable for small components. You can mix both in one app, and even in one component, since setup() works alongside options.

The same counter in both styles. The logic is identical; what changes is where it lives:

<!-- Options API: grouped by option type -->
<script>
export default {
  data: () => ({ count: 0 }),
  computed: { double() { return this.count * 2 } },
  methods: { increment() { this.count++ } },
}
</script>

<!-- Composition API: grouped by feature -->
<script setup>
import { ref, computed } from 'vue'
const count = ref(0)
const double = computed(() => count.value * 2)
const increment = () => count.value++
</script>

Choose the Composition API when:

  • components have several distinct concerns, or are likely to grow,
  • you want to share stateful logic across components,
  • you're using TypeScript,
  • you're starting a new project (it's what the Vue docs, Pinia and most of the ecosystem now show first).

The Options API is still fine when:

  • you're maintaining a Vue 2 or early Vue 3 codebase,
  • components are small and simple,
  • the team is more familiar with it.

The rest of this track uses <script setup>.

Key takeaways

  • Options API and Composition API are two organisational styles over the same reactivity engine.
  • Options API groups code by type (data, methods…). Composition API groups by feature and lets you extract features into composables.
  • <script setup> is compiler sugar for setup(). Top-level bindings are exposed to the template automatically, and macros like defineProps need no import.
  • No this means better TypeScript inference, better minification and fewer arrow-function bugs. The cost is .value on refs in JavaScript.
  • Default to <script setup> for new work, but you can explain why the Options API still has its place.