Chapter 42: Vue Composition API
Vue Composition API — the single biggest change and improvement that happened in Vue 3.
I’m going to explain it like I’m sitting next to you, pair-programming, showing you why almost every serious Vue developer in 2026 uses it instead of the old Options API.
1. What problem does Composition API solve? (The honest reason it exists)
Imagine you’re building a medium/large component in the old Options API style:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
export default { data() { return { count: 0, name: '', email: '', isLoading: false } }, computed: { fullName() { ... }, isFormValid() { ... } }, methods: { increment() { ... }, validate() { ... }, fetchUser() { ... } }, watch: { name(newVal) { ... } }, mounted() { this.fetchUser() }, beforeUnmount() { clearInterval(this.timer) } } |
After 200–300 lines it becomes very hard to:
- See which data/methods/computeds/watchers belong together logically
- Reuse logic between components (you end up with mixins → nightmare)
- Extract a piece of logic into its own file
- Type it properly with TypeScript
Composition API fixes exactly this by letting you organize code by logical concern instead of by type.
You group related:
- state (ref/reactive)
- computed values
- methods/functions
- watchers
- lifecycle hooks
- composables (reusable logic blocks)
…all together in one place — usually inside <script setup>.
2. The Heart: <script setup> (2026 standard – 95%+ of new code)
This is the modern, cleanest way to use Composition API.
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
<script setup> import { ref, computed, watch, onMounted } from 'vue' // All your logic lives here — grouped by feature const count = ref(0) const name = ref('') const email = ref('') const fullName = computed(() => `${name.value} ${email.value.split('@')[0]}`) function increment() { count.value++ } watch(name, (newName) => { console.log('Name changed to:', newName) }) onMounted(() => { console.log('Component mounted – fetch initial data here') }) </script> |
→ No export default { } → No this → No separation by data/computed/methods/watch → Everything is just normal JavaScript at the top level
3. Core Building Blocks of Composition API
| Concept | Old Options API | Composition API (script setup) | Why it’s better |
|---|---|---|---|
| Reactive state | data() { return { count: 0 } } | const count = ref(0) | No this, explicit reactivity |
| Reactive objects | data() { user: {} } | const user = reactive({ name: ”, age: 0 }) | Deep reactivity without .value |
| Computed | computed: { double() { … } } | const double = computed(() => count.value * 2) | Normal function, auto .value in template |
| Watch | watch: { count(new) { … } } | watch(count, (newVal, oldVal) => { … }) | More flexible, can watch getters / arrays |
| Lifecycle | mounted() { … } | onMounted(() => { … }) | Imported functions – can be used anywhere |
| Props | props: { name: String } | const props = defineProps<{ name: string }>() | Type-safe, no this |
| Emits | emits: [‘update’] | const emit = defineEmits<{ (e: ‘update’): void }>() | Type-safe events |
4. Real, Complete Example – User Profile Form (shows grouping by concern)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
<template> <div class="profile-form"> <h2>Edit Profile</h2> <div class="field"> <label>Name</label> <input v-model="user.name" /> </div> <div class="field"> <label>Email</label> <input v-model="user.email" type="email" /> </div> <div class="field"> <label>Age</label> <input v-model.number="user.age" type="number" /> </div> <p>Full name preview: {{ fullName }}</p> <p>Form is valid: {{ isFormValid ? 'Yes' : 'No' }}</p> <button :disabled="!isFormValid || isSaving" @click="saveProfile" > {{ isSaving ? 'Saving…' : 'Save Profile' }} </button> <p v-if="saveError" class="error">{{ saveError }}</p> </div> </template> <script setup lang="ts"> import { reactive, computed, watch, ref, onMounted } from 'vue' // ── Group 1: State ──────────────────────────────────────── const user = reactive({ name: '', email: '', age: 0 }) const isSaving = ref(false) const saveError = ref<string | null>(null) // ── Group 2: Derived / Computed ─────────────────────────── const fullName = computed(() => { return user.name.trim() ? `${user.name.trim()} (${user.email.split('@')[0]})` : 'Anonymous' }) const isFormValid = computed(() => { return ( user.name.trim().length >= 2 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email) && user.age >= 18 && user.age <= 120 ) }) // ── Group 3: Watchers ───────────────────────────────────── watch(() => user.email, (newEmail) => { if (newEmail.includes('gmail')) { console.log('Gmail user detected – maybe send welcome email?') } }) // ── Group 4: Methods / Actions ──────────────────────────── async function saveProfile() { if (!isFormValid.value) return isSaving.value = true saveError.value = null try { // Fake API call await new Promise(r => setTimeout(r, 1200)) console.log('Profile saved:', user) alert('Profile updated successfully!') } catch (err: any) { saveError.value = err.message || 'Failed to save profile' } finally { isSaving.value = false } } // ── Group 5: Lifecycle ──────────────────────────────────── onMounted(() => { // Simulate loading existing profile user.name = 'Rahul Sharma' user.email = 'rahul@example.com' user.age = 28 console.log('Profile loaded on mount') }) </script> <style scoped> .profile-form { max-width: 500px; margin: 3rem auto; padding: 2rem; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); } .field { margin-bottom: 1.5rem; } label { display: block; margin-bottom: 0.5rem; font-weight: 500; } input { width: 100%; padding: 0.8rem; border: 1px solid #d1d5db; border-radius: 6px; } button { width: 100%; padding: 0.9rem; background: #3b82f6; color: white; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; } button:disabled { opacity: 0.6; cursor: not-allowed; } .error { color: #dc2626; margin-top: 1rem; text-align: center; } </style> |
Why Composition API Wins in 2026 (Summary Table)
| Problem / Pain Point | Options API (old) | Composition API (<script setup>) | Winner |
|---|---|---|---|
| Logical grouping | Separated by type → hard to follow related code | Grouped by feature → very readable | Composition |
| Code reuse | Mixins → naming conflicts, hard to trace | Composables → clean, importable functions | Composition |
| TypeScript experience | Painful (this.$refs, this.$emit…) | Excellent – normal JS + defineProps/Emits | Composition |
| Large components (200+ lines) | Becomes spaghetti | Still readable – logic stays together | Composition |
| Extracting logic to separate file | Mixins or very awkward | Just make a composable → useCounter() | Composition |
| Boilerplate | Lots (export default, methods:, etc.) | Minimal | Composition |
Quick Rules of Thumb (2026)
- New project → always use <script setup> + Composition API
- Legacy / old tutorials → you will see Options API — learn to read it, but write Composition
- Need to share logic? → make a composable (useAuth, useForm, useFetch)
- Need global state? → Pinia (not provide/inject for most cases)
- Keep composables pure & testable — no DOM access inside them
Your next mini-project after this lesson:
- Convert any Options API component you find online to <script setup>
- Extract counter logic + validation into useCounter.js composable
- Use it in two different components
Any part still confusing? Want to see:
- Full useForm composable example with validation?
- Composition API + Pinia + router in one project?
- How to gradually migrate old Options API code?
- Composition vs Options side-by-side comparison?
Just tell me — we’ll build the next clean Composition API feature together 🚀
Happy composing from Hyderabad! 💙
