# Vue.js Composition API: Complete Tutorial

# Vue.js Composition API: Complete Tutorial

The Vue.js Composition API has fundamentally changed how developers architect complex frontend applications, yet many teams still struggle with component logic reusability, type safety, and state management at scale. In 2025, applications demand real-time reactivity across distributed systems, seamless integration with AI-driven features, and maintainable codebases that support rapid iteration. The Options API, while functional for simple use cases, creates fragmented logic across lifecycle hooks, making it nearly impossible to extract and test business logic independently or share stateful behavior across components without resorting to mixins—a pattern now widely recognized as an anti-pattern due to namespace collisions and implicit dependencies.

The consequences of sticking with outdated patterns are tangible: development velocity drops as teams spend 40-60% more time debugging implicit state mutations, TypeScript integration remains superficial without proper type inference, and code duplication proliferates as developers copy-paste logic rather than compose reusable functions. Modern applications processing real-time data streams, managing complex form validation, or coordinating multiple API calls simultaneously need a composition-based approach that treats logic as first-class, composable units.

## Why Traditional Vue Patterns Fail Modern Requirements

The Options API organizes code by option type (data, methods, computed, watch), forcing related logic to scatter across multiple sections. When building a feature like real-time collaborative editing or complex data filtering with multiple interdependent states, this organization becomes a maintenance nightmare. A single feature's logic might span data declarations at the top, computed properties in the middle, watchers further down, and methods at the bottom—making it impossible to understand the feature holistically without scrolling through hundreds of lines.

Mixins, the traditional solution for code reuse, introduce implicit dependencies and property name conflicts. When multiple mixins define the same property, the last one wins with no warning. In 2025's microservices-driven frontend architectures where components consume data from multiple sources, this unpredictability is unacceptable. Teams need explicit, traceable dependencies that TypeScript can validate at compile time.

The shift toward edge computing and serverless rendering in 2025-2026 demands smaller bundle sizes and better tree-shaking. The Composition API enables this through explicit imports—bundlers can eliminate unused code paths with precision impossible in the Options API's implicit this-based context.

## Understanding the Vue.js Composition API Architecture

The Composition API introduces `setup()`, a single entry point that executes before component creation. This function returns reactive state and methods that the template can access. Unlike the Options API's implicit this context, everything is explicit—you import what you need and return what the template uses.

The core primitives are `ref()` for primitive reactivity, `reactive()` for object reactivity, `computed()` for derived state, and `watch()` for side effects. These functions create reactive references that Vue's reactivity system tracks automatically.

Here's a production-grade example demonstrating real-time data synchronization with proper error handling and TypeScript integration:

```typescript
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import type { Ref } from 'vue'

interface DataPoint {
  id: string
  timestamp: number
  value: number
  metadata: Record<string, unknown>
}

interface WebSocketMessage {
  type: 'update' | 'delete' | 'batch'
  payload: DataPoint | DataPoint[]
}

export function useRealtimeData(endpoint: string) {
  const data: Ref<Map<string, DataPoint>> = ref(new Map())
  const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('connecting')
  const error = ref<Error | null>(null)
  const reconnectAttempts = ref(0)
  
  let ws: WebSocket | null = null
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
  const MAX_RECONNECT_ATTEMPTS = 5
  const RECONNECT_DELAY = 2000

  const sortedData = computed(() => {
    return Array.from(data.value.values())
      .sort((a, b) => b.timestamp - a.timestamp)
  })

  const latestValue = computed(() => {
    const sorted = sortedData.value
    return sorted.length > 0 ? sorted[0].value : null
  })

  function connect() {
    try {
      ws = new WebSocket(endpoint)
      
      ws.onopen = () => {
        connectionStatus.value = 'connected'
        reconnectAttempts.value = 0
        error.value = null
      }

      ws.onmessage = (event) => {
        try {
          const message: WebSocketMessage = JSON.parse(event.data)
          handleMessage(message)
        } catch (err) {
          error.value = err instanceof Error ? err : new Error('Parse error')
        }
      }

      ws.onerror = (event) => {
        error.value = new Error('WebSocket error occurred')
        connectionStatus.value = 'disconnected'
      }

      ws.onclose = () => {
        connectionStatus.value = 'disconnected'
        attemptReconnect()
      }
    } catch (err) {
      error.value = err instanceof Error ? err : new Error('Connection failed')
      attemptReconnect()
    }
  }

  function handleMessage(message: WebSocketMessage) {
    switch (message.type) {
      case 'update':
        const point = message.payload as DataPoint
        data.value.set(point.id, point)
        break
      case 'delete':
        const deletePoint = message.payload as DataPoint
        data.value.delete(deletePoint.id)
        break
      case 'batch':
        const points = message.payload as DataPoint[]
        points.forEach(p => data.value.set(p.id, p))
        break
    }
  }

  function attemptReconnect() {
    if (reconnectAttempts.value >= MAX_RECONNECT_ATTEMPTS) {
      error.value = new Error('Max reconnection attempts reached')
      return
    }

    reconnectAttempts.value++
    reconnectTimer = setTimeout(() => {
      connect()
    }, RECONNECT_DELAY * reconnectAttempts.value)
  }

  function disconnect() {
    if (reconnectTimer) {
      clearTimeout(reconnectTimer)
      reconnectTimer = null
    }
    if (ws) {
      ws.close()
      ws = null
    }
  }

  onMounted(() => {
    connect()
  })

  onUnmounted(() => {
    disconnect()
  })

  // Watch for connection issues and log metrics
  watch(connectionStatus, (newStatus, oldStatus) => {
    if (newStatus === 'disconnected' && oldStatus === 'connected') {
      console.warn('Connection lost, attempting reconnect')
    }
  })

  return {
    data: sortedData,
    latestValue,
    connectionStatus,
    error,
    reconnectAttempts,
    disconnect
  }
}
```

This composable encapsulates all real-time data logic—connection management, error handling, automatic reconnection, and reactive state updates. Any component can import and use it without duplicating code.

## Building Composables for Complex State Management

Composables are the Composition API's killer feature—reusable functions that encapsulate reactive state and logic. Unlike mixins, composables have explicit inputs and outputs, making dependencies traceable and testable.

Here's a composable for managing complex form state with validation, debouncing, and submission handling:

```typescript
import { ref, reactive, computed, watch } from 'vue'
import type { Ref, UnwrapRef } from 'vue'

interface ValidationRule<T> {
  validate: (value: T) => boolean
  message: string
}

interface FieldConfig<T> {
  initialValue: T
  rules?: ValidationRule<T>[]
  debounce?: number
}

interface FormConfig {
  [key: string]: FieldConfig<any>
}

export function useForm<T extends FormConfig>(config: T) {
  type FormData = {
    [K in keyof T]: T[K]['initialValue']
  }

  const formData = reactive<FormData>(
    Object.entries(config).reduce((acc, [key, field]) => {
      acc[key as keyof FormData] = field.initialValue
      return acc
    }, {} as FormData)
  )

  const errors = reactive<Record<keyof T, string[]>>(
    Object.keys(config).reduce((acc, key) => {
      acc[key as keyof T] = []
      return acc
    }, {} as Record<keyof T, string[]>)
  )

  const touched = reactive<Record<keyof T, boolean>>(
    Object.keys(config).reduce((acc, key) => {
      acc[key as keyof T] = false
      return acc
    }, {} as Record<keyof T, boolean>)
  )

  const isSubmitting = ref(false)
  const submitError = ref<Error | null>(null)

  const isValid = computed(() => {
    return Object.values(errors).every(fieldErrors => fieldErrors.length === 0)
  })

  const isDirty = computed(() => {
    return Object.values(touched).some(t => t === true)
  })

  function validateField<K extends keyof T>(fieldName: K): boolean {
    const fieldConfig = config[fieldName]
    const value = formData[fieldName]
    const fieldErrors: string[] = []

    if (fieldConfig.rules) {
      for (const rule of fieldConfig.rules) {
        if (!rule.validate(value)) {
          fieldErrors.push(rule.message)
        }
      }
    }

    errors[fieldName] = fieldErrors
    return fieldErrors.length === 0
  }

  function validateAll(): boolean {
    let allValid = true
    for (const fieldName of Object.keys(config) as Array<keyof T>) {
      const valid = validateField(fieldName)
      if (!valid) allValid = false
    }
    return allValid
  }

  function setFieldValue<K extends keyof T>(
    fieldName: K,
    value: UnwrapRef<FormData[K]>
  ) {
    formData[fieldName] = value
    touched[fieldName] = true
    validateField(fieldName)
  }

  async function handleSubmit<R>(
    submitFn: (data: FormData) => Promise<R>
  ): Promise<R | null> {
    if (!validateAll()) {
      return null
    }

    isSubmitting.value = true
    submitError.value = null

    try {
      const result = await submitFn(formData)
      return result
    } catch (err) {
      submitError.value = err instanceof Error ? err : new Error('Submit failed')
      return null
    } finally {
      isSubmitting.value = false
    }
  }

  function reset() {
    Object.entries(config).forEach(([key, field]) => {
      formData[key as keyof FormData] = field.initialValue
      errors[key as keyof T] = []
      touched[key as keyof T] = false
    })
    submitError.value = null
  }

  // Setup debounced validation watchers
  Object.keys(config).forEach((key) => {
    const fieldConfig = config[key as keyof T]
    if (fieldConfig.debounce) {
      let timeoutId: ReturnType<typeof setTimeout>
      watch(
        () => formData[key as keyof FormData],
        () => {
          clearTimeout(timeoutId)
          timeoutId = setTimeout(() => {
            if (touched[key as keyof T]) {
              validateField(key as keyof T)
            }
          }, fieldConfig.debounce)
        }
      )
    }
  })

  return {
    formData,
    errors,
    touched,
    isValid,
    isDirty,
    isSubmitting,
    submitError,
    setFieldValue,
    validateField,
    validateAll,
    handleSubmit,
    reset
  }
}
```

Using this composable in a component:

```typescript
import { useForm } from '@/composables/useForm'

const { formData, errors, isValid, handleSubmit, setFieldValue } = useForm({
  email: {
    initialValue: '',
    rules: [
      {
        validate: (v) => v.length > 0,
        message: 'Email is required'
      },
      {
        validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
        message: 'Invalid email format'
      }
    ],
    debounce: 300
  },
  password: {
    initialValue: '',
    rules: [
      {
        validate: (v) => v.length >= 8,
        message: 'Password must be at least 8 characters'
      }
    ]
  }
})

async function onSubmit() {
  const result = await handleSubmit(async (data) => {
    return await api.login(data.email, data.password)
  })
  
  if (result) {
    router.push('/dashboard')
  }
}
```

## Integrating with TypeScript for Type-Safe Components

The Composition API's explicit nature makes TypeScript integration seamless. Generic types flow through composables naturally, providing autocomplete and compile-time validation.

Define strict types for your composables:

```typescript
import type { Ref, ComputedRef } from 'vue'

export interface UseAsyncStateReturn<T, E = Error> {
  data: Ref<T | null>
  error: Ref<E | null>
  isLoading: Ref<boolean>
  execute: () => Promise<void>
  reset: () => void
}

export function useAsyncState<T, E = Error>(
  fetcher: () => Promise<T>,
  options?: {
    immediate?: boolean
    onSuccess?: (data: T) => void
    onError?: (error: E) => void
  }
): UseAsyncStateReturn<T, E> {
  const data = ref<T | null>(null)
  const error = ref<E | null>(null)
  const isLoading = ref(false)

  async function execute() {
    isLoading.value = true
    error.value = null

    try {
      const result = await fetcher()
      data.value = result
      options?.onSuccess?.(result)
    } catch (err) {
      error.value = err as E
      options?.onError?.(err as E)
    } finally {
      isLoading.value = false
    }
  }

  function reset() {
    data.value = null
    error.value = null
    isLoading.value = false
  }

  if (options?.immediate !== false) {
    execute()
  }

  return {
    data,
    error,
    isLoading,
    execute,
    reset
  }
}
```

## Common Pitfalls and Edge Cases

**Reactivity Loss with Destructuring**: Destructuring reactive objects breaks reactivity. Always use `toRefs()` when destructuring:

```typescript
// Wrong - loses reactivity
const { count } = reactive({ count: 0 })

// Correct
const state = reactive({ count: 0 })
const { count } = toRefs(state)
```

**Ref Unwrapping Confusion**: Refs auto-unwrap in templates but not in JavaScript. This causes bugs when passing refs between composables:

```typescript
const count = ref(0)
// In template: {{ count }} works
// In JS: count.value is required
```

**Watch Timing Issues**: Watchers run asynchronously by default. Use `flush: 'sync'` for immediate execution when needed, but be aware of performance implications:

```typescript
watch(source, callback, { flush: 'sync' }) // Runs synchronously
```

**Memory Leaks with Event Listeners**: Always clean up side effects in `onUnmounted`:

```typescript
onMounted(() => {
  window.addEventListener('resize', handler)
})

onUnmounted(() => {
  window.removeEventListener('resize', handler)
})
```

**Computed Property Mutations**: Never mutate computed values. They should be pure functions:

```typescript
// Wrong
const doubled = computed(() => {
  count.value++ // Side effect in computed
  return count.value * 2
})

// Correct
const doubled = computed(() => count.value * 2)
```

## Best Practices for Production Applications

**Organize Composables by Domain**: Structure composables around business domains, not technical concerns. Create `useUserAuthentication`, `useProductCatalog`, not `useApi`, `useState`.

**Implement Proper Error Boundaries**: Wrap async operations in try-catch blocks and expose error states:

```typescript
const { data, error, isLoading } = useAsyncState(fetchData)

// In template
if (error.value) {
  // Show error UI
}
```

**Use Provide/Inject for Deep Component Trees**: Avoid prop drilling by providing context at the root:

```typescript
// Parent
provide('theme', readonly(theme))

// Deep child
const theme = inject<Theme>('theme')
```

**Leverage Script Setup Syntax**: Use `<script setup>` for cleaner component code:

```typescript
<script setup lang="ts">
import { useForm } from '@/composables/useForm'

const { formData, handleSubmit } = useForm({...})
</script>
```

**Implement Composable Testing**: Test composables in isolation using `@vue/test-utils`:

```typescript
import { mount } from '@vue/test-utils'
import { useCounter } from './useCounter'

test('counter increments', () => {
  const wrapper = mount({
    setup() {
      return useCounter()
    },
    template: '<div></div>'
  })
  
  const { increment, count } = wrapper.vm
  increment()
  expect(count.value).toBe(1)
})
```

**Optimize Bundle Size**: Import only what you need from Vue:

```typescript
import { ref, computed } from 'vue' // Not import Vue from 'vue'
```

**Document Composable APIs**: Use JSDoc for IDE autocomplete:

```typescript
/**
 * Manages real-time data synchronization via WebSocket
 * @param endpoint - WebSocket URL
 * @returns Reactive data, connection status, and control methods
 */
export function useRealtimeData(endpoint: string) {
  // ...
}
```

## Frequently Asked Questions

**What is the Vue.js Composition API and why use it in 2025?**

The Composition API is a set of functions for organizing component logic by feature rather than option type. In 2025, it's essential for building scalable applications because it enables code reuse through composables, provides superior TypeScript support, and allows better tree-shaking for smaller bundles. Modern applications with complex state management, real-time features, and AI integrations require the flexibility and composability that only the Composition API provides.

**How does the Composition API differ from the Options API?**

The Options API organizes code by option type (data, methods, computed), scattering related logic across multiple sections. The Composition API groups related logic together in composable functions. The Options API uses implicit `this` context, while the Composition API uses explicit imports and returns. For TypeScript users, the Composition API provides full type inference without manual type annotations.

**When should you avoid using the Composition API?**

Avoid the Composition API for extremely simple components with minimal logic—a single prop and template with no state management. For teams with no TypeScript experience and simple applications, the Options API's structure might be more intuitive initially. However, as applications grow, the Composition API's benefits
