Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 005 / 100

Composition API vs Options API for Vue & Nuxt developers

Which should you use?

Expona Admin7 min read

Composition API vs Options API for Vue & Nuxt developers

If you're building Vue or Nuxt applications today, you've likely encountered both the Composition API and the Options API. Both are valid approaches, but they lead to different code organization patterns, TypeScript ergonomics, and long-term maintainability. We'll walk through when and why to use each, and how to avoid common pitfalls when choosing between them.

Understanding the two approaches

The Options API groups component logic by concern type: data here, methods there, watchers elsewhere. It's what most Vue 2 developers know well. The Composition API, introduced in Vue 3, lets you group logic by feature, organizing related state and behavior together in composable functions.

Both work in Vue 3 and Nuxt 3. Neither is going away. But they solve problems differently, and picking the right one early saves refactoring pain later.

Composition API: better TypeScript and reuse

The Composition API shines when you need strong type inference and want to share logic across components without prop drilling or render props. Since everything is just functions and objects, TypeScript can infer types naturally without special plugin support.

Consider a real-world e-commerce scenario: you need a hook that fetches products, handles pagination, and manages loading state. With the Composition API:

typescript
import { ref, computed, watch } from 'vue'
import type { Product } from '@/types'

export function useProductFetch(initialPage = 1) {
  const page = ref(initialPage)
  const products = ref<Product[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)
  const pageSize = 20

  const total = ref(0)
  const hasNextPage = computed(() => page.value * pageSize < total.value)

  const fetchProducts = async () => {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(
        `/api/products?page=${page.value}&limit=${pageSize}`
      )
      const data = await response.json()
      products.value = data.items
      total.value = data.total
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Failed to fetch'
    } finally {
      loading.value = false
    }
  }

  watch(page, fetchProducts, { immediate: true })

  return {
    page,
    products,
    loading,
    error,
    hasNextPage,
    fetchProducts,
  }
}

Now you use this in multiple components without any boilerplate:

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

const { products, loading, page, hasNextPage, fetchProducts } = useProductFetch()
</script>

<template>
  <div>
    <div v-if="loading">Loading...</div>
    <ul>
      <li v-for="product in products" :key="product.id">
        {{ product.name }}
      </li>
    </ul>
    <button 
      v-if="hasNextPage" 
      @click="page++"
      :disabled="loading"
    >
      Next Page
    </button>
  </div>
</template>

TypeScript inference works seamlessly. You get autocomplete on every ref and computed property. The logic is testable in isolation—just call the function and assert on returned refs. This approach scales well in larger Nuxt applications where you're pulling data from multiple services and orchestrating complex state.

Options API: familiar and still supported

The Options API remains the right choice for simpler components and teams that prioritize readability over maximum code reuse. It's still fully supported in Vue 3 and works in Nuxt 3. If your team is primarily mid-level developers or you're maintaining legacy code, Options API can be easier to reason about at first glance.

The same product fetching component in Options API:

typescript
<script lang="ts">
import type { Product } from '@/types'

interface State {
  page: number
  products: Product[]
  loading: boolean
  error: string | null
  total: number
}

export default {
  data(): State {
    return {
      page: 1,
      products: [],
      loading: false,
      error: null,
      total: 0,
    }
  },
  computed: {
    pageSize(): number {
      return 20
    },
    hasNextPage(): boolean {
      return this.page * this.pageSize < this.total
    },
  },
  watch: {
    page: 'fetchProducts',
  },
  mounted() {
    this.fetchProducts()
  },
  methods: {
    async fetchProducts() {
      this.loading = true
      this.error = null
      try {
        const response = await fetch(
          `/api/products?page=${this.page}&limit=${this.pageSize}`
        )
        const data = await response.json()
        this.products = data.items
        this.total = data.total
      } catch (e) {
        this.error = e instanceof Error ? e.message : 'Failed to fetch'
      } finally {
        this.loading = false
      }
    },
  },
}
</script>

The structure is predictable. Everyone knows where to find computed properties, methods, and lifecycle hooks. For teams that change frequently or have many junior developers, this familiarity is valuable. However, you'll notice type inference is less automatic—you need the State interface, and `this` context can be tricky.

The real friction appears when you want to reuse this logic across five components. With Options API, you'd typically reach for mixins, which introduce their own problems.

Composables replace mixins

In Vue 2, when you needed shared logic across components, mixins were the standard tool. They merge object properties into the component, which makes it hard to trace where data or methods actually come from. Two mixins with properties of the same name cause silent conflicts. Debugging becomes painful.

The Composition API killed the need for mixins. Composables are just functions that return reactive objects. Name collisions don't exist because you're explicitly destructuring what you use.

If you're stuck with Options API in Vue 3 or Nuxt 3, you can still extract logic, but you lose clarity:

typescript
// Mixin (problematic)
const paginationMixin = {
  data() {
    return { page: 1, pageSize: 20 }
  },
  computed: {
    hasNextPage() { /* ... */ }
  },
  methods: {
    nextPage() { this.page++ }
  }
}

// In a component
export default {
  mixins: [paginationMixin],
  // Where did 'page' come from? A mixin. Which mixin? Search for it.
}

Composables are transparent and composable (pun intended):

typescript
const { page, hasNextPage, nextPage } = usePagination()
// Clear. Explicit. Testable in isolation.

For new projects, use composables over mixins. For teams on Options API, composables can be used alongside it—they're not mutually exclusive. You return the composable's values in your component's data or computed properties.

New projects favor Composition API

The Vue and Nuxt teams have made a clear long-term bet: Composition API is the future. It's the recommended approach in official documentation, and most new libraries (state management, form handling, routing) provide Composition API examples first.

If you're starting a Nuxt 3 SaaS application or a trading dashboard today, use Composition API. Here's why:

  • TypeScript integration: No special plugins needed. Types flow naturally through your code.
  • Scalability: Large components stay readable because related logic lives together in composables.
  • Testing: Composables are plain functions. Mock dependencies, call the function, assert the result.
  • Tree-shaking: Unused composable functions are stripped from production bundles more effectively.
  • Developer momentum: Most team members you hire will expect Composition API in Vue 3 projects.

In Nuxt 3 specifically, server routes, middleware, and plugins already use modern async patterns that pair well with Composition API. Your mental model stays consistent across the framework.

Common mistakes

Mixing both styles in the same component

You can use both in one component, but it's confusing. If a component uses <script setup> (Composition API), don't also define Options API properties. Pick one style per component, and keep your codebase consistent.

Over-extracting with composables

Not every piece of state needs its own composable. A simple toggle for a modal can stay inline. Extract when logic is used in two or more places, or when a single composable has become more than ~100 lines and handles multiple concerns.

Forgetting to return from composables

Composables must explicitly return the reactive data and methods. Beginners sometimes forget to return a new value in a computed, or forget to return from the composable entirely.

TypeScript with Options API and mixins

If you're using Options API with multiple mixins in TypeScript, type inference breaks down. The component doesn't know which properties come from which mixin. Composition API sidesteps this entirely.

Key takeaways

  • Use Composition API for new Vue 3 and Nuxt 3 projects—better TypeScript, clearer code organization, easier testing.
  • Options API remains valid for simpler components or teams prioritizing immediate familiarity over long-term scalability.
  • Replace mixins with composables to eliminate name collisions and make code intent explicit.
  • Composables are functions that return reactive objects; they're not tied to a specific API style.
  • Don't mix Composition and Options API in the same component. Pick one and commit to it per-component, ideally per-project.
  • TypeScript integration is dramatically smoother with Composition API—no special type augmentation needed.
  • Extract logic into a composable when it's used in two or more places, or when a single component exceeds ~200 lines.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles