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
| Feature | Description |
|---|---|
| Reactive Data Binding | When data changes, the UI updates automatically |
| Component-Based | Build UIs with reusable, independent units |
| Progressive Adoption | Apply 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.
| Item | Vue 2 | Vue 3 |
|---|---|---|
| API Style | Options API only | Options API + Composition API |
| Performance | Baseline | Up to 55% faster rendering |
| Bundle Size | ~20KB gzip | ~10KB gzip (stronger tree-shaking) |
| TypeScript | Limited support | Full TypeScript support |
| Multiple Root Elements | Not allowed | Fragment support (allowed) |
| Teleport | Not available | Built-in support |
| Suspense | Not available | Experimental support |
| v-model | .sync modifier | Multiple 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>
Basic Example — <script setup> syntax (Vue 3.2+, recommended)
<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.