Skip to main content
Advertisement

14.1 Introduction to Vue.js — Options API vs Composition API, Key Changes in Vue 3

What is Vue.js?

Vue.js is a progressive JavaScript framework for building user interfaces. The word "progressive" means you can incrementally adopt Vue into an existing HTML page, or use it to build a full Single Page Application (SPA) from scratch.

Vue was first released by Evan You in 2014, and has since become one of the most popular front-end frameworks in the world, alongside React and Angular.

Core Philosophy of Vue.js

FeatureDescription
Reactive Data BindingWhen data changes, the UI updates automatically
Component-BasedBuild UIs with reusable, independent units
Progressive AdoptionApply incrementally to existing projects
Single File Component (SFC)Write template, script, and style together in a .vue file

Vue 2 vs Vue 3 Comparison

Vue 3 was officially released in 2020, featuring significant improvements over Vue 2.

ItemVue 2Vue 3
API StyleOptions API onlyOptions API + Composition API
PerformanceBaselineUp to 55% faster rendering
Bundle Size~20KB gzip~10KB gzip (stronger tree-shaking)
TypeScriptLimited supportFull TypeScript support
Multiple Root ElementsNot allowedFragment support (allowed)
TeleportNot availableBuilt-in support
SuspenseNot availableExperimental support
v-model.sync modifierMultiple v-model support

What is the Options API?

The Options API is the traditional style used since Vue 2. Component logic is defined as an object with options such as data, methods, computed, and watch.

Basic Example — Options API

<!-- Counter.vue (Options API) -->
<template>
<div class="counter">
<h2>Counter: {{ count }}</h2>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">Reset</button>
</div>
</template>

<script>
export default {
name: 'Counter',

// Reactive data
data() {
return {
count: 0,
}
},

// Computed properties
computed: {
doubled() {
return this.count * 2
},
},

// Methods
methods: {
increment() {
this.count++
},
decrement() {
this.count--
},
reset() {
this.count = 0
},
},

// Lifecycle hook
mounted() {
console.log('Component mounted. Initial count:', this.count)
},

// Watchers
watch: {
count(newVal, oldVal) {
console.log(`count changed: ${oldVal} → ${newVal}`)
},
},
}
</script>

<style scoped>
.counter {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
button {
margin: 0.25rem;
padding: 0.5rem 1rem;
}
</style>

The Options API has a clear, beginner-friendly structure. However, as components grow, code related to the same feature becomes scattered across data, computed, methods, watch, and other sections, making maintenance harder.


What is the Composition API?

The Composition API is a new API style introduced in Vue 3. Instead of organizing logic by option blocks, it uses functions to compose component logic. It is conceptually similar to React Hooks, but operates on top of Vue's own reactivity system.

Basic Example — Composition API (with setup() function)

<!-- Counter.vue (Composition API, setup function) -->
<template>
<div class="counter">
<h2>Counter: {{ count }}</h2>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">Reset</button>
</div>
</template>

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

export default {
name: 'Counter',

setup() {
// ref: single reactive value
const count = ref(0)

// computed: derived reactive value
const doubled = computed(() => count.value * 2)

// Methods are plain functions
const increment = () => { count.value++ }
const decrement = () => { count.value-- }
const reset = () => { count.value = 0 }

// Lifecycle
onMounted(() => {
console.log('Mounted. Initial count:', count.value)
})

// Watcher
watch(count, (newVal, oldVal) => {
console.log(`count changed: ${oldVal} → ${newVal}`)
})

// Return values and functions for the template
return { count, doubled, increment, decrement, reset }
},
}
</script>

<script setup> is a compiler macro that lets you write Composition API more concisely. All variables and functions are available in the template without an explicit return.

<!-- Counter.vue (<script setup> — modern standard) -->
<template>
<div class="counter">
<h2>Counter: {{ count }}</h2>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">Reset</button>
</div>
</template>

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

const count = ref(0)
const doubled = computed(() => count.value * 2)

const increment = () => { count.value++ }
const decrement = () => { count.value-- }
const reset = () => { count.value = 0 }

onMounted(() => {
console.log('Mounted. Initial count:', count.value)
})

watch(count, (newVal, oldVal) => {
console.log(`count changed: ${oldVal} → ${newVal}`)
})
</script>

<style scoped>
.counter {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
button {
margin: 0.25rem;
padding: 0.5rem 1rem;
}
</style>

Options API vs Composition API — In-Depth Comparison

The Code Cohesion Problem

The diagram below illustrates how code for a "search + pagination" feature gets scattered across option blocks with the Options API, versus how it stays cohesive with the Composition API.

Options API (code for the same feature is scattered):
┌──────────────────────────────────────┐
│ data() ← search query, page num │
│ computed ← filtered results, total │
│ methods ← search fn, page fn │
│ watch ← watch query changes │
└──────────────────────────────────────┘
↑ Feature A's code spread across 4 sections

Composition API (cohesive by feature):
┌──────────────────────────────────────┐
│ // --- Search Logic --- │
│ const query = ref('') │
│ const results = computed(...) │
│ const search = () => { ... } │
│ watch(query, search) │
│ │
│ // --- Pagination Logic --- │
│ const page = ref(1) │
│ const totalPages = computed(...) │
│ const goToPage = (n) => { ... } │
└──────────────────────────────────────┘
↑ Code grouped by feature

Composables — Logic Reuse

The biggest advantage of the Composition API is composables, which allow stateful logic to be extracted and reused across components.

// composables/useCounter.js — reusable counter logic
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)

const increment = () => { count.value++ }
const decrement = () => { count.value-- }
const reset = () => { count.value = initialValue }

return { count, doubled, increment, decrement, reset }
}
<!-- Reusable in any component -->
<script setup>
import { useCounter } from '@/composables/useCounter'

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

Key Changes in Vue 3

1. createApp() API Change

// Vue 2
import Vue from 'vue'
import App from './App.vue'
new Vue({ render: h => h(App) }).$mount('#app')

// Vue 3
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

2. Global API Tree-shaking

In Vue 3, functions like nextTick, ref, and computed are explicitly imported, reducing bundle size.

// Vue 2 — global Vue object
Vue.nextTick(() => { /* ... */ })

// Vue 3 — explicit import
import { nextTick } from 'vue'
nextTick(() => { /* ... */ })

3. v-model Changes

<!-- Vue 2 -->
<MyComponent :value="title" @input="title = $event" />
<!-- or shorthand -->
<MyComponent v-model="title" />

<!-- Vue 3: default prop is now 'modelValue' -->
<MyComponent v-model="title" />
<!-- Multiple v-model bindings are also supported -->
<MyComponent v-model:title="title" v-model:content="content" />

4. Fragments (Multiple Root Elements)

<!-- Vue 2: template must have a single root element -->
<template>
<div> <!-- required wrapper -->
<h1>Title</h1>
<p>Content</p>
</div>
</template>

<!-- Vue 3: multiple root elements allowed (Fragment) -->
<template>
<h1>Title</h1>
<p>Content</p>
</template>

5. Teleport

<!-- The component lives deep in the tree, but renders directly to body -->
<template>
<button @click="open = true">Open Modal</button>

<Teleport to="body">
<div v-if="open" class="modal">
<p>Modal content</p>
<button @click="open = false">Close</button>
</div>
</Teleport>
</template>

<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>

6. Emits Option

<script setup>
// Explicitly declare events to emit (type safety + IDE autocomplete)
const emit = defineEmits(['change', 'submit'])

const handleChange = (value) => {
emit('change', value)
}
</script>

Practical Example — Options API and Composition API Side by Side

The same "Todo List" component implemented in both styles.

Options API Version

<template>
<div>
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="Enter a task..." />
<button @click="addTodo">Add</button>
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">Delete</button>
</li>
</ul>
<p>Done: {{ doneCount }} / Total: {{ todos.length }}</p>
<button @click="filter = 'all'">All</button>
<button @click="filter = 'active'">Active</button>
<button @click="filter = 'done'">Completed</button>
</div>
</template>

<script>
export default {
data() {
return {
newTodo: '',
filter: 'all',
todos: [
{ id: 1, text: 'Learn Vue 3', done: false },
{ id: 2, text: 'Master Composition API', done: false },
],
}
},
computed: {
doneCount() {
return this.todos.filter(t => t.done).length
},
filteredTodos() {
if (this.filter === 'active') return this.todos.filter(t => !t.done)
if (this.filter === 'done') return this.todos.filter(t => t.done)
return this.todos
},
},
methods: {
addTodo() {
if (!this.newTodo.trim()) return
this.todos.push({ id: Date.now(), text: this.newTodo.trim(), done: false })
this.newTodo = ''
},
removeTodo(id) {
this.todos = this.todos.filter(t => t.id !== id)
},
},
}
</script>

Composition API (<script setup>) Version

<template>
<div>
<input v-model="newTodo" @keyup.enter="addTodo" placeholder="Enter a task..." />
<button @click="addTodo">Add</button>
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input type="checkbox" v-model="todo.done" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)">Delete</button>
</li>
</ul>
<p>Done: {{ doneCount }} / Total: {{ todos.length }}</p>
<button @click="filter = 'all'">All</button>
<button @click="filter = 'active'">Active</button>
<button @click="filter = 'done'">Completed</button>
</div>
</template>

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

const newTodo = ref('')
const filter = ref('all')
const todos = reactive([
{ id: 1, text: 'Learn Vue 3', done: false },
{ id: 2, text: 'Master Composition API', done: false },
])

const doneCount = computed(() => todos.filter(t => t.done).length)

const filteredTodos = computed(() => {
if (filter.value === 'active') return todos.filter(t => !t.done)
if (filter.value === 'done') return todos.filter(t => t.done)
return todos
})

const addTodo = () => {
if (!newTodo.value.trim()) return
todos.push({ id: Date.now(), text: newTodo.value.trim(), done: false })
newTodo.value = ''
}

const removeTodo = (id) => {
const idx = todos.findIndex(t => t.id === id)
if (idx !== -1) todos.splice(idx, 1)
}
</script>

<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>

Pro Tips

When to Use Options API vs Composition API

  • Options API: Migrating from Vue 2, small components, teaching Vue to beginners
  • Composition API: All new Vue 3 projects, components with complex stateful logic, when logic reuse matters, when using TypeScript

The Vue core team recommends Composition API + <script setup> for new projects.

New Features in Vue 3.5

<script setup>
// Vue 3.5: useTemplateRef — type-safe template ref
import { useTemplateRef, onMounted } from 'vue'

const inputEl = useTemplateRef('myInput')

onMounted(() => {
inputEl.value?.focus()
})
</script>

<template>
<input ref="myInput" type="text" />
</template>
<script setup>
// Vue 3.5: Reactive destructuring of defineProps
const { title, count = 0 } = defineProps(['title', 'count'])
// ↑ In versions before Vue 3.5, reactivity was lost on destructure —
// now it is fully supported
</script>

Mixins vs Composables

// ❌ Vue 2 Mixin — unclear origin, risk of name collisions
const searchMixin = {
data() { return { query: '' } },
methods: { search() { /* ... */ } }
}

// ✅ Vue 3 Composable — explicit and type-safe
// composables/useSearch.js
export function useSearch() {
const query = ref('')
const search = () => { /* ... */ }
return { query, search }
}

Using TypeScript

<script setup lang="ts">
import { ref, computed } from 'vue'

interface Todo {
id: number
text: string
done: boolean
}

const todos = ref<Todo[]>([])
const doneCount = computed<number>(() => todos.value.filter(t => t.done).length)

const addTodo = (text: string): void => {
todos.value.push({ id: Date.now(), text, done: false })
}
</script>

Because Vue 3 was rewritten entirely in TypeScript, simply adding lang="ts" provides excellent type inference out of the box.

Advertisement