Code | Solutions

What You’ll Build and Learn

If you’re new to Vue 3 or you’ve heard the buzz about the Composition API, this guide is your friendly on-ramp. You’ll learn the mental model behind ref, reactive, computed, watch, lifecycle hooks, and how to package logic into reusable composables. Along the way we’ll write bite-sized examples and finish with a tiny Todo app you can extend.

Who This Guide Is For

  • JavaScript devs comfortable with ES6.
  • Vue 2 users curious about Vue 3.
  • Beginners who want a clear, practical introduction without fluff.

    Prerequisites and Setup

    • Node 18+ is ideal.
    • Create a project quickly with Vite:
    npm create vite@latest my-vue3-app -- --template vue
    cd my-vue3-app
    npm install
    npm run dev
    

    Open the project in your editor and you’re set.

    Vue 3 at a Glance

    Vue 3 is a progressive framework for building user interfaces. It’s fast, approachable, and scales from tiny widgets to full apps.

    What Changed from Vue 2

    • Composition API for composing logic by feature, not by option.
    • Performance improvements and a smaller runtime.
    • TypeScript support is first-class.
    • Fragments, Teleport, and Suspense out of the box.

    Why Choose Vue 3 in 2025

    • A thriving ecosystem (Vite, Pinia, Vue Router).
    • Gentle learning curve compared to some alternatives.
    • Excellent DX with <script setup> and top-level await.

    Options API vs. Composition API

    Both APIs render the same templates. The difference is how you organize code.

    The “Logic Grouping” Problem

    In the Options API, related logic gets scattered across data, methods, computed, watch. With Composition API, you keep all logic for a feature together inside setup() or a reusable function.

    When the Options API Still Makes Sense

    • Tiny components with minimal logic.
    • Teams migrating gradually.
    • Developers who prefer a more declarative, “kitchen-labeled” structure.

    Your First Composition API Component

    Using <script setup>

    <script setup> is the idiomatic way to write single-file components in Vue 3. It’s compiled away, so your code is lean and fast.

    <script setup>
    import { ref, computed, onMounted } from 'vue'
    
    const count = ref(0)               // primitive or single value state
    const double = computed(() => count.value * 2)
    
    function increment() {
      count.value++
    }
    
    onMounted(() => {
      console.log('Component mounted!')
    })
    </script>
    
    <template>
      <button @click="increment">
        Clicked {{ count }} times (x2 = {{ double }})
      </button>
    </template>

    State with ref

    • ref(0) wraps a value and exposes it as .value.
    • In templates, Vue unwraps refs automatically, so {{ count }} just works.

    Derived State with computed

    • computed caches results until its dependencies change.
    • Perfect for totals, filters, formatting, or expensive calculations.

    reactive, toRef, and toRefs

    reactive makes an object reactive—great for grouped state.

    import { reactive, toRefs, toRef } from 'vue'
    
    const state = reactive({
      user: { id: 1, name: 'Ada' },
      items: [],
      loading: false
    })
    
    // toRefs preserves reactivity when destructuring:
    const { user, items, loading } = toRefs(state)
    
    // single property as a ref:
    const itemCount = toRef(state, 'items')
    

    When to Prefer reactive

    • When state is naturally an object or you have many related fields.
    • When you want to pass around a single container of state.

    Avoiding Destructuring Reactivity Gotchas

    Don’t do:

    const { user } = state // ❌ loses reactivity

    Do:

    const { user } = toRefs(state) // ✅ keeps reactivity

    Lifecycle Hooks in Composition API

    Import and call hooks directly in setup().

    import { onMounted, onUpdated, onUnmounted } from 'vue'
    
    onMounted(() => { /* subscribe, fetch, focus */ })
    onUpdated(() => { /* respond to DOM changes */ })
    onUnmounted(() => { /* cleanup timers, listeners */ })

    Common Hooks (onMounted, onUnmounted, onUpdated)

    • Use onMounted for fetching remote data or DOM work.
    • Always clean up in onUnmounted to avoid leaks.

    Route-Aware Hooks (Heads-Up)

    With Vue Router you also get:

    • onBeforeRouteLeave to block navigation (e.g., unsaved changes).
    • onBeforeRouteUpdate to react when route params change.

    Watchers: watch vs watchEffect

    import { ref, watch, watchEffect } from 'vue'
    
    const query = ref('')
    const results = ref([])
    
    watch(query, async (newQ, oldQ) => {
      results.value = await search(newQ)
    })
    
    watchEffect(() => {
      // Runs immediately and tracks dependencies it touches
      console.log('Current results length:', results.value.length)
    })

    Deep Watching and Cleanup

    • watch(obj, cb, { deep: true }) observes nested changes—but prefer computed values or targeted refs to avoid heavy work.
    • Return a cleanup function in watchEffect if you start effects like intervals or subscriptions.

    Performance Tips for Watchers

    • Watch the minimal source (() => state.items.length) rather than the whole object.
    • Debounce expensive operations when responding to rapid input.

    Template Refs and DOM Access

    <template>
      <input ref="inputEl" placeholder="Focus me on mount" />
    </template>
    
    <script setup>
    import { ref, onMounted } from 'vue'
    const inputEl = ref(null)
    
    onMounted(() => {
      inputEl.value?.focus()
    })
    </script>

    Focusing Inputs and Reading Sizes

    Use template refs for focusing, measuring elements, or integrating non-Vue libraries.

    shallowRef for Non-Vue Objects

    For large third-party instances (e.g., a chart), use shallowRef to avoid deep proxying:

    import { shallowRef } from 'vue'
    const chart = shallowRef(null)

    Props, Emits, and Typing

    defineProps / defineEmits

    <script setup lang="ts">
    const props = defineProps<{
      modelValue: string
      max?: number
    }>()
    
    const emit = defineEmits<{
      (e: 'update:modelValue', value: string): void
      (e: 'limit'): void
    }>()
    
    function onInput(v: string) {
      if (props.max && v.length > props.max) return emit('limit')
      emit('update:modelValue', v)
    }
    </script>
    
    <template>
      <input :value="modelValue" @input="onInput(($event.target as HTMLInputElement).value)" />
    </template>

    TypeScript-Friendly Patterns

    Using <script setup lang="ts"> gives strong types for props and emits, catching mistakes early.

    Provide/Inject for Cross-Tree State

    Pass data to deep descendants without prop drilling.

    // Parent
    import { provide } from 'vue'
    provide('theme', 'dark')
    
    // Deep child
    import { inject } from 'vue'
    const theme = inject('theme', 'light') // 'light' is a fallback

    Scoped Injections

    Provide different tokens per subtree to vary behavior (e.g., different themes for modals vs. main app).

    Fallbacks and Debugging

    Always give a fallback to avoid undefined surprises in tests or storybook environments.

    Building Reusable Composables

    A useCounter Example

    // composables/useCounter.ts
    import { ref, computed } from 'vue'
    
    export function useCounter(initial = 0, step = 1) {
      const count = ref(initial)
      const double = computed(() => count.value * 2)
      const inc = () => (count.value += step)
      const dec = () => (count.value -= step)
      const reset = () => (count.value = initial)
      return { count, double, inc, dec, reset }
    }

    Use it in any component:

    const { count, inc, double } = useCounter(10, 2)

    A useFetch Example with Error Handling

    // composables/useFetch.ts
    import { ref, onMounted } from 'vue'
    
    export function useFetch<T = unknown>(url: string, immediate = true) {
      const data = ref<T | null>(null)
      const error = ref<Error | null>(null)
      const loading = ref(false)
    
      const execute = async () => {
        loading.value = true
        error.value = null
        try {
          const res = await fetch(url)
          if (!res.ok) throw new Error(`Request failed: ${res.status}`)
          data.value = (await res.json()) as T
        } catch (e) {
          error.value = e as Error
        } finally {
          loading.value = false
        }
      }
    
      if (immediate) onMounted(execute)
      return { data, error, loading, execute }
    }

    Async and Suspense

    Top-Level await in <script setup>

    You can await directly:

    <script setup>
    const user = await (await fetch('/api/me')).json()
    </script>
    
    <template>
      <pre>{{ user }}</pre>
    </template>

    Graceful Loading States

    Wrap async components with <Suspense> and provide a fallback:

    <Suspense>
      <template #default>
        <UserCard />
      </template>
      <template #fallback>
        <div>Loading user…</div>
      </template>
    </Suspense>

    Routing with Vue Router

    useRoute and useRouter

    import { useRoute, useRouter } from 'vue-router'
    const route = useRoute()
    const router = useRouter()
    
    function goHome() {
      router.push({ name: 'home' })
    }

    onBeforeRouteLeave / onBeforeRouteUpdate

    Keep forms safe and respond to param changes:

    import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
    
    onBeforeRouteLeave((to, from) => {
      // return false to cancel
    })
    
    onBeforeRouteUpdate((to, from) => {
      // react to route param changes
    })

    State Management with Pinia

    Defining a Store

    // stores/cart.ts
    import { defineStore } from 'pinia'
    
    export const useCartStore = defineStore('cart', {
      state: () => ({ items: [] as { id: number; name: string }[] }),
      getters: {
        count: (s) => s.items.length,
      },
      actions: {
        add(item: { id: number; name: string }) {
          this.items.push(item)
        },
        remove(id: number) {
          this.items = this.items.filter((i) => i.id !== id)
        },
      },
    })

    Using storeToRefs in Components

    <script setup lang="ts">
    import { storeToRefs } from 'pinia'
    import { useCartStore } from '@/stores/cart'
    
    const cart = useCartStore()
    const { items, count } = storeToRefs(cart)
    
    function addRandom() {
      cart.add({ id: Date.now(), name: 'Gizmo' })
    }
    </script>
    
    <template>
      <p>Items: {{ count }}</p>
      <ul><li v-for="i in items" :key="i.id">{{ i.name }}</li></ul>
      <button @click="addRandom">Add</button>
    </template>

    Forms and Validation

    v-model with the Composition API

    v-model works the same; for custom components, emit update:modelValue.

    <script setup>
    const text = ref('')
    </script>
    
    <template>
      <input v-model="text" placeholder="Type here" />
      <p>You typed: {{ text }}</p>
    </template>

    Validation Approaches

    • Light: Hand-roll checks in watch/computed.
    • Medium: Composables that return { errors, validate }.
    • Heavy: Libraries like VeeValidate or custom schema checkers.

    Testing Components and Composables

    Unit-Testing a Composable with Vitest

    // composables/useCounter.spec.ts
    import { describe, it, expect } from 'vitest'
    import { useCounter } from './useCounter'
    
    describe('useCounter', () => {
      it('increments and doubles', () => {
        const { count, inc, double } = useCounter(1, 2)
        inc()
        expect(count.value).toBe(3)
        expect(double.value).toBe(6)
      })
    })

    Component Testing Basics

    Use Testing Library or Vue Test Utils to render components and assert text or DOM behavior.

    Migration Tips from Options to Composition

    Mapping Options to setup()

    • dataref/reactive
    • computedcomputed
    • methods → plain functions
    • watchwatch/watchEffect
    • mountedonMounted, etc.

    Progressively Adopting Composition API

    You can mix APIs in the same app. Convert complex components first to reap the benefits of grouped logic, and leave simple ones on Options for now.

    Common Pitfalls and How to Fix Them

    Losing Reactivity via Destructuring

    Problem:

    const { user } = state // ❌ breaks tracking

    Fix:

    const { user } = toRefs(state) // ✅

    Overusing Deep Watch

    Avoid deep: true unless you truly need it. Often you can watch a specific computed value instead, which is faster and easier to reason about.

    Other quick wins:

    • Don’t mutate props; create local state.
    • Cleanup effects in onUnmounted.
    • Prefer computed over watch when deriving values.

    Mini Project: A Tiny Todo with Filters

    Folder Structure and Store

    src/
      composables/
        useTodos.ts
      components/
        TodoInput.vue
        TodoList.vue
      App.vue

    useTodos.ts

    import { ref, computed } from 'vue'
    
    export type Todo = { id: number; text: string; done: boolean }
    
    export function useTodos() {
      const todos = ref<Todo[]>([])
      const filter = ref<'all' | 'active' | 'done'>('all')
    
      const visible = computed(() => {
        if (filter.value === 'active') return todos.value.filter(t => !t.done)
        if (filter.value === 'done') return todos.value.filter(t => t.done)
        return todos.value
      })
    
      function add(text: string) {
        const clean = text.trim()
        if (!clean) return
        todos.value.push({ id: Date.now(), text: clean, done: false })
      }
    
      function toggle(id: number) {
        const t = todos.value.find(t => t.id === id)
        if (t) t.done = !t.done
      }
    
      function remove(id: number) {
        todos.value = todos.value.filter(t => t.id !== id)
      }
    
      return { todos, filter, visible, add, toggle, remove }
    }

    TodoInput.vue

    <script setup>
    import { ref } from 'vue'
    const emit = defineEmits(['add'])
    const text = ref('')
    const submit = () => {
      emit('add', text.value)
      text.value = ''
    }
    </script>
    
    <template>
      <form @submit.prevent="submit" class="row">
        <input v-model="text" placeholder="What needs doing?" />
        <button>Add</button>
      </form>
    </template>

    TodoList.vue

    <script setup>
    const props = defineProps({
      items: { type: Array, required: true },
    })
    const emit = defineEmits(['toggle', 'remove'])
    </script>
    
    <template>
      <ul>
        <li v-for="t in items" :key="t.id">
          <label>
            <input type="checkbox" :checked="t.done" @change="emit('toggle', t.id)" />
            <span :style="{ textDecoration: t.done ? 'line-through' : 'none' }">{{ t.text }}</span>
          </label>
          <button @click="emit('remove', t.id)">×</button>
        </li>
      </ul>
    </template>

    App.vue

    <script setup>
    import { useTodos } from './composables/useTodos'
    import TodoInput from './components/TodoInput.vue'
    import TodoList from './components/TodoList.vue'
    
    const { visible, filter, add, toggle, remove } = useTodos()
    </script>
    
    <template>
      <h1>Todos</h1>
    
      <TodoInput @add="add" />
    
      <div>
        <button @click="filter = 'all'">All</button>
        <button @click="filter = 'active'">Active</button>
        <button @click="filter = 'done'">Done</button>
      </div>
    
      <TodoList :items="visible" @toggle="toggle" @remove="remove" />
    </template>

    This tiny app demonstrates refs, computed, emits, and composables—exactly the heart of the Composition API.

    Adding a Composable and Tests

    Extract filtering logic or persistence (e.g., useLocalStorage) into a composable. Then unit-test it with Vitest to ensure it behaves under edge cases.

    Next Steps and Learning Path

    Recommended Ecosystem Tools

    • Vite for bundling and dev server.
    • Pinia for global state.
    • Vue Router for navigation.
    • Vitest for testing.
    • ESLint + Prettier for consistent code.

    Where to Go From Here

    • Convert an existing Options API component to Composition API.
    • Write your first reusable composable and publish it internally.
    • Layer TypeScript for stronger safety as your project grows.

    Conclusion

    The Composition API in Vue 3 gives you a clean, scalable way to organize component logic, especially as features become more complex. With ref, reactive, computed, and watchers, you can model state precisely. Lifecycle hooks keep effects tidy, while composables let you package and share behavior across components. Add Vue Router and Pinia, and you’ve got a modern, productive stack ready for real-world apps. Start small—convert one component, extract one composable—and you’ll quickly feel the difference in clarity and maintainability.

    FAQs

    1) Is the Composition API harder than the Options API?
    Not really—just different. Once you write a few components with <script setup>, the mental model clicks.

    2) Do I have to rewrite my whole app to use Composition API?
    No. Mix both APIs. Move complex components first and leave simple ones on Options until it’s worth changing.

    3) Should I use ref or reactive for arrays and objects?
    Both work. Use ref<T[]>([]) when you frequently reassign the whole array; use reactive({ items: [] }) when the collection is part of a larger state object.

    4) When should I choose watch over computed?
    computed is for deriving values; watch is for side effects (fetching, logging, saving).

    5) Do I need Pinia if I have composables?
    Composables are great for logic reuse. If you need cross-page state with devtools, persistence, and structure, Pinia is a natural fit.

    Recent News