Why Vue 3 Changed Everything

Vue 3 wasn’t just an incremental update. The Composition API fundamentally changed how we think about component logic. After months of daily use, here are the patterns and concepts that actually matter.

Composition API vs Options API

The Options API splits logic by option type — data, methods, computed, watch. The Composition API splits logic by concern.

Counter.vue
javascript
<script setup>
// All related logic lives together
const count = ref(0)
const doubled = computed(() => count.value * 2)

function increment() {
count.value++
}

watch(count, (newVal) => {
console.log('Count changed:', newVal)
})
</script>

This matters when components grow. You can extract related logic into composables instead of scattering it across options.

ref vs reactive

This confused me for weeks. Here’s the simple rule:

typescript
// ref — primitives and reassignable values
const count = ref(0)
const name = ref('Jay')

// reactive — objects (no .value needed)
const state = reactive({ count: 0, name: 'Jay' })

When to use which:

  • Use ref for most things. It’s explicit and works with primitives.
  • Use reactive for complex objects that won’t be reassigned.
  • Never destructure reactive — it breaks reactivity.
typescript
// ❌ Breaks reactivity
const { count } = reactive({ count: 0 })

// ✅ Works
const state = reactive({ count: 0 })
const count = toRef(state, 'count')

Composables: The Real Power

Composables are Vue 3’s answer to mixins. They’re just functions that use reactive state.

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

function increment() {
  count.value++
}

return { count, doubled, increment }
}
App.vue
javascript
<script setup>
const { count, doubled, increment } = useCounter(10)
</script>

Why this is better than mixins:

  • No name collisions
  • Explicit inputs/outputs
  • Easy to test
  • TypeScript-friendly

Watch Patterns

Vue 3 gives you fine-grained watchers:

typescript
// Watch a ref
watch(count, (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`)
})

// Watch multiple sources
watch([count, name], ([newCount, newName]) => {
console.log(newCount, newName)
})

// Watch reactive object property
watch(() => state.count, (newVal) => {
console.log(newVal)
})

// Deep watch
watch(state, (newVal) => {
console.log('Anything changed')
}, { deep: true })

// Immediate execution
watch(count, (newVal) => {
console.log(newVal)
}, { immediate: true })

script setup: The Standard

<script setup> is the recommended way to write Vue 3 components. It’s less boilerplate, better TypeScript inference, and cleaner code.

ChildComponent.vue
javascript
<script setup>
// Imports are automatically available in template
import { ref, computed } from 'vue'
import ChildComponent from './ChildComponent.vue'

const props = defineProps({
title: String,
count: { type: Number, default: 0 }
})

const emit = defineEmits(['update'])

const localState = ref(0)
</script>

<template>
<h1>{{ title }}</h1>
<ChildComponent :count="localState" />
</template>

Real-World Pattern: useApi Composable

Here’s a composable I use in production:

useApi.ts
typescript
export function useApi<T>(url: string) {
const data = ref<T | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)

async function fetch() {
  loading.value = true
  error.value = null
  try {
    const res = await window.fetch(url)
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    data.value = await res.json()
  } catch (e) {
    error.value = e instanceof Error ? e.message : 'Unknown error'
  } finally {
    loading.value = false
  }
}

return { data, error, loading, fetch }
}

Usage:

UserList.vue
javascript
<script setup>
const { data: users, loading, fetch: loadUsers } = useApi('/api/users')

onMounted(() => loadUsers())
</script>

<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
  <li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>

Key Takeaways

  1. Use ref by default — it’s explicit and flexible
  2. Composables over mixins — extract, reuse, test
  3. <script setup> everywhere — less boilerplate, better DX
  4. Watch specific values — avoid deep watches when possible
  5. Don’t destructure reactive objects — use toRef/toRefs instead

Vue 3’s Composition API isn’t just a new syntax. It’s a better mental model for building components. Once it clicks, going back feels wrong.