Vue 3: Composition API in Practice — Organizing Real-World Logic in Vue Components

Aprenda a organizar lógica de busca, filtros e paginação em componentes Vue 3 usando Composition API e composables reutilizáveis.

Gabriel's avatar
Gabriel
Vue 3: Composition API in Practice — Organizing Real-World Logic in Vue Components

Vue 3: Composition API in Practice — Organizing Real-World Logic in Vue Components

This article is for people who already use Vue, like the Options API, but have started to feel that their components are becoming too large and difficult to maintain.


TL;DR

  • Vue 3: Composition API in Practice is not “another Vue”: it is simply a different way to organize the same logic.
  • Instead of separating code into “boxes” (data, methods, computed), you organize it by features (search, filters, pagination, loading…).
  • The fundamental building blocks are ref, reactive, computed, watch/watchEffect, and setup() (or <script setup>).
  • Composables are functions that encapsulate a reactive responsibility (for example, useSearchableList, usePagination, and useAsyncData).
  • The Composition API shines in medium and large components and for reusable logic; the Options API remains an excellent choice for simple components.
  • Start gradually: pick one difficult component today and refactor it with the Composition API.

If you want to go deeper, there is a complementary article in English called “Vue 3 Composition API in Practice”, covering a broader software engineering context, on the DW Corp website: DW Corp’s “Vue 3 Composition API in Practice” article.


1. Why move beyond “basic Vue” and look at the Composition API?

If you have already built a dashboard with search + filters + pagination + loading + error state in Vue, you have probably experienced something like this:

  • The component started small… and became a monster with hundreds of lines.
  • The logic is scattered across data, methods, computed, and watch.
  • Reusing the same search logic on another screen means… copy and paste (or using somewhat magical mixins).

When the Options API starts to hurt in practice

The Options API works very well when:

  • The component has one clear responsibility.
  • The logic is small enough to fit into a few options.

Now think again about the dashboard with:

  • Search
  • Status filters
  • Pagination
  • Loading and error handling
  • Perhaps even automatic updates (polling)

You end up with something like this:

  • data: items, searchTerm, filters, currentPage, isLoading, error, …
  • computed: filteredItems, paginatedItems, hasMorePages, …
  • methods: fetchItems, applyFilters, handleSearch, goToPage, reload, …
  • watch: searchTerm, filters, currentPage

In other words: everything is mixed together, and it is difficult to look at the code and understand which functionality each piece belongs to.

What you will learn here

Using the example of a dashboard with search + filters + pagination, we will see in practice how to:

  • Better organize logic inside a component using the Composition API.
  • Extract parts of that logic into reusable composables (for example, useSearchableList, usePagination, and useAsyncData).
  • Understand when it makes sense to use the Composition API and when the Options API remains a good choice.

2. What is the Composition API (without buzzwords)?

Diagram comparing two Vue components: one organized by options such as data and methods, and another organized by features such as search, filters, and pagination.
Diagram comparing two Vue components: one organized by options such as data and methods, and another organized by features such as search, filters, and pagination.

It is not “another Vue”

The Composition API is not a new framework or a “secret advanced mode.” It is an alternative way to declare the same logic that you would already write with the Options API.

The shift in mindset is:

  • Options API: you separate code by type of thing (data, methods, computed, watch…).
  • Composition API: you separate code by feature (search, filters, pagination, request state…).

In practice, this means:

  • Everything related to “search” can stay together (state + computed values + watchers + API calls).
  • Everything related to “pagination” stays together.
  • Everything related to “request state” (loading/error/data) stays together.

Where the Composition API appears

You will find the Composition API in three main places:

  1. setup() inside components

ts import { defineComponent, ref } from 'vue'

export default defineComponent({ setup(props, context) { const searchTerm = ref('')

   // use ref, reactive, computed, watch...

   return {
     searchTerm
     // what the template can access
   }
 }

})

  1. <script setup> in Single File Components (SFCs)

vue
<script setup lang="ts"> import { ref } from 'vue' const searchTerm = ref<string>('') </script>

<template> <input v-model="searchTerm" /> </template>

  1. Reusable functions (composables)

ts import { ref, computed } from 'vue'

export interface SearchableItem { name: string [key: string]: unknown }

export function useSearchableList<T extends SearchableItem>( initialItems: T[] = [] ) { const items = ref<T[]>(initialItems) const searchTerm = ref<string>('')

 const filteredItems = computed<T[]>(() => {
   const term = searchTerm.value.toLowerCase()
   return items.value.filter(item =>
     item.name.toLowerCase().includes(term)
   )
 })

 return {
   items,
   searchTerm,
   filteredItems
 }

}

This function can be imported into any component, reusing the same logic clearly.

Vue 3: Composition API in Practice vs. Options API

  • Options API
  • Very good for getting started.
  • Excellent for simple components.
  • The component “shape” is predictable (it always has data, methods, and so on).

  • Vue 3: Composition API in Practice

  • More comfortable in medium and large components.
  • More powerful for reusing logic.
  • Makes it easier to organize code by domain/feature, rather than by “option type.”

Both APIs coexist in Vue 3. The official Composition API documentation also includes an FAQ section that discusses its motivations, benefits, and trade-offs in detail.


3. Composition API fundamentals in practice

Before discussing composables, let’s go through the basic building blocks, already using TypeScript.

3.1. Reactivity with ref and reactive

ref: simple, individual values

ref creates a reactive value “wrapped” in an object with a .value property:

ts import { ref } from 'vue'

const searchTerm = ref<string>('') // reactive string const currentPage = ref<number>(1) // reactive number const isLoading = ref<boolean>(false) // reactive boolean

  • Inside the code, you read and write the value using .value:

ts searchTerm.value = 'vue' console.log(currentPage.value)

  • In the template, you do not use .value:

vue <template> <input v-model="searchTerm" />

Current page: {{ currentPage }}

</template>

A typical pitfall:

  • Forgetting .value in TypeScript code.
  • Adding .value inside the template.

reactive: objects and grouped state

When you have state that is naturally an object (for example, combined filters or form data), reactive is often more natural:

ts import { reactive } from 'vue'

interface Filters { status: 'all' | 'open' | 'closed' minDate: Date | null maxDate: Date | null }

const filters = reactive<Filters>({ status: 'all', minDate: null, maxDate: null })

You access its properties normally:

ts filters.status = 'open' console.log(filters.minDate)

But there is an important detail:

If you destructure a reactive object, reactivity is lost in the destructured variables.

Problematic example:

ts // ❌ DO NOT do this const { status, minDate } = filters // status and minDate are now copies, not reactive values

Quick mental rules:

  • ref
  • For primitive values (string, number, boolean).
  • For state you manipulate independently (for example, currentPage and searchTerm).

  • reactive

  • For objects that represent cohesive state (for example, filters, form, and queryParams).
  • Avoid destructuring the object, or make sure you know exactly what you are doing.

3.2. Deriving values with computed

computed produces a value that depends on other reactive values and is recalculated automatically when its dependencies change.

In our dashboard:

ts import { ref, computed } from 'vue'

interface Item { id: number name: string status: 'published' | 'draft' }

const items = ref<Item[]>([ { id: 1, name: 'Vue 3 Guide', status: 'published' }, { id: 2, name: 'Composition API Tips', status: 'draft' } ])

const searchTerm = ref<string>('vue')

const filteredItems = computed<Item[]>(() => { const term = searchTerm.value.toLowerCase() return items.value.filter(item => item.name.toLowerCase().includes(term) ) })

In the template:

vue <template> <input v-model="searchTerm" placeholder="Search..." />

  • {{ item.name }}

</template>

Here, you clearly separate:

  • Source state (items, searchTerm).
  • Derived logic (filteredItems).

3.3. Reacting to changes with watch and watchEffect

Sometimes you need side effects:

  • Call an API when a filter changes.
  • Save something to localStorage.
  • Synchronize the URL with internal state.

When watch makes sense

watch observes one or more specific reactive sources and runs a function when they change:

ts import { ref, watch } from 'vue'

const searchTerm = ref<string>('') const currentPage = ref<number>(1)

watch( [searchTerm, currentPage], ([newSearch, newPage], [oldSearch, oldPage]) => { // you have access to the new and previous values fetchItems({ search: newSearch, page: newPage }) } )

async function fetchItems(params: { search: string; page: number }) { // API call... }

Typical scenarios for watch:

  • API calls in response to filter or pagination changes.
  • Manual debounce/throttle logic.
  • State persistence (for example, saving user settings).

watchEffect vs. watch

watchEffect runs the function immediately and automatically tracks the reactive dependencies used inside it:

ts import { ref, watchEffect } from 'vue'

const searchTerm = ref<string>('') const currentPage = ref<number>(1)

watchEffect(() => { fetchItems({ search: searchTerm.value, page: currentPage.value }) })

Important differences:

  • watch:
  • You explicitly declare what is being observed.
  • Ideal when you want fine-grained control (comparing previous values, configuring flush, and so on).

  • watchEffect:

  • Faster to write.
  • Useful for simple effects tied to several pieces of state.
  • Can become confusing if the function starts accessing many states (the dependencies become “hidden”).

4. From setup() to <script setup>: organizing the component

4.1. Anatomy of a component with the Composition API

Let’s imagine a DashboardList.vue component with search, filters, pagination, loading, and errors.

With setup(), the mental structure is:

  1. Input: props, emit/context.
  2. State: ref, reactive.
  3. Derived logic: computed.
  4. Effects: watch, watchEffect, and lifecycle hooks.
  5. Return value: what the template can use.

Schematic example:

ts import { defineComponent, ref, reactive, computed, watch, onMounted } from 'vue'

interface Filters { status: string }

interface Item { id: number name: string }

export default defineComponent({ props: { initialStatus: { type: String, default: 'all' } }, setup(props, { emit }) { // 1. state const searchTerm = ref<string>('') const currentPage = ref<number>(1) const filters = reactive<Filters>({ status: props.initialStatus }) const items = ref<Item[]>([]) const isLoading = ref<boolean>(false) const error = ref<string | null>(null)

// 2. derived values
const filteredItems = computed<Item[]>(() => {
  // uses items, searchTerm, filters...
  const term = searchTerm.value.toLowerCase()
  return items.value.filter(item =>
    item.name.toLowerCase().includes(term)
  )
})

// 3. effects
async function fetchItems() {
  // API call using filters and pagination...
}

watch(
  [searchTerm, () => filters.status, currentPage],
  () => {
    fetchItems()
  }
)

// 4. lifecycle
onMounted(() => {
  fetchItems()
})

// 5. return values for the template
return {
  searchTerm,
  filters,
  currentPage,
  items,
  filteredItems,
  isLoading,
  error,
  fetchItems
}

} })

Common pitfalls:

  • Declaring a ref or computed value and forgetting to return it from setup().
  • Filling a single setup() block without visually separating responsibilities.

4.2. Using <script setup> day to day

<script setup> is a shortcut for the Composition API in SFCs:

  • You do not need to declare setup() manually.
  • Everything declared inside <script setup> is automatically available in the template.
  • There is less ceremony and more focus on the logic.

Equivalent example using <script setup> and TypeScript:

vue

<script setup lang="ts"> import { ref, reactive, computed, watch, onMounted } from 'vue' interface Filters { status: string } interface Item { id: number name: string } // props const props = defineProps<{ initialStatus?: string }>() // 1. state const searchTerm = ref<string>('') const currentPage = ref<number>(1) const filters = reactive<Filters>({ status: props.initialStatus ?? 'all' }) const items = ref<Item[]>([]) const isLoading = ref<boolean>(false) const error = ref<string | null>(null) // 2. derived values const filteredItems = computed<Item[]>(() => { const term = searchTerm.value.toLowerCase() return items.value.filter(item => item.name.toLowerCase().includes(term) ) }) // 3. effects async function fetchItems(): Promise<void> { // API call } watch( [searchTerm, () => filters.status, currentPage], () => { fetchItems() } ) // 4. lifecycle onMounted(() => { fetchItems() }) </script>

<template>

</template>

A simple order that works well for most components:

  1. Imports.
  2. defineProps / defineEmits.
  3. Reactive state (ref / reactive).
  4. computed values.
  5. watch/watchEffect/hooks.
  6. Action functions (for example, fetchItems and goToPage).
  7. Initial calls (onMounted, and so on).

Whenever it makes sense, visually group code by “feature”:

  • Search block
  • Filters block
  • Pagination block
  • Request state block

This sets the stage for the next step: extracting composables.


5. Composables: extracting and genuinely reusing logic

5.1. What a composable is (and is not)

Abstract illustration of modular blocks representing reusable functions, with code icons and arrows showing reuse across components.
Abstract illustration of modular blocks representing reusable functions, with code icons and arrows showing reuse across components.

A composable is:

A function that uses the Composition API (ref, reactive, computed, watch, and lifecycle hooks) to encapsulate a reactive responsibility.

Examples of typical responsibilities:

  • Managing search and filters for a list (useSearchableList).
  • Controlling pagination (usePagination).
  • Centralizing asynchronous request logic with loading and error state (useAsyncData).

What is not necessarily a composable:

  • Pure utility functions that only receive data and return a result (for example, formatCurrency and parseDate). These can—and should—remain normal helpers without ref or reactive.

Difference from mixins:

  • Mixins inject properties and methods into a component “magically.”
  • Composables are explicit functions: you import them, call them, and receive a “package” of state and functions.

5.2. Building a composable step by step (with TypeScript)

Let’s take one part of the dashboard screen: a searchable list with loading state.

1) Logic inline in the component

vue

<script setup lang="ts"> import { ref, computed, watch, onMounted } from 'vue' // import { api } from '@/services/api' interface Item { id: number name: string } const searchTerm = ref<string>('') const items = ref<Item[]>([]) const isLoading = ref<boolean>(false) const error = ref<string | null>(null) async function fetchItems(): Promise<void> { isLoading.value = true error.value = null try { // API call // const response = await api.get<Item[]>('/items', { // params: { search: searchTerm.value } // }) // items.value = response.data // for this example, let’s simulate it: items.value = [ { id: 1, name: 'Vue 3 Guide' }, { id: 2, name: 'Composition API in Practice' } ] } catch (err) { error.value = 'Error loading items' } finally { isLoading.value = false } } const filteredItems = computed<Item[]>(() => { const term = searchTerm.value.toLowerCase() return items.value.filter(item => item.name.toLowerCase().includes(term) ) }) watch(searchTerm, () => { fetchItems() }) onMounted(() => { fetchItems() }) </script>

It works, but this combination of state + fetch + loading + error will probably be repeated.

2) Extracting it into useSearchableList()

ts // useSearchableList.ts import { ref, computed, watch, onMounted } from 'vue'

export interface UseSearchableListParams<TSearchParams> { fetchFn: (params: TSearchParams) => Promise<unknown[]> buildParams: (search: string) => TSearchParams immediate?: boolean }

export function useSearchableList<TItem, TSearchParams = { search: string }>({ fetchFn, buildParams, immediate = true }: UseSearchableListParams<TSearchParams>) { const searchTerm = ref<string>('') const items = ref<TItem[]>([]) const isLoading = ref<boolean>(false) const error = ref<string | null>(null)

async function load(): Promise<void> { isLoading.value = true error.value = null

try {
  const params = buildParams(searchTerm.value)
  const data = await fetchFn(params)
  items.value = data as TItem[]
} catch (err) {
  error.value =
    err instanceof Error ? err.message : 'Error loading items'
} finally {
  isLoading.value = false
}

}

const filteredItems = computed<TItem[]>(() => { const term = searchTerm.value.toLowerCase() return items.value.filter((item: any) => String(item.name ?? '') .toLowerCase() .includes(term) ) })

watch(searchTerm, () => { void load() })

if (immediate) { onMounted(() => { void load() }) }

return { // state searchTerm, items, filteredItems, isLoading, error, // actions reload: load } }

Important points:

  • The composable receives fetchFn and buildParams: it does not know how to fetch data; it only coordinates the process.
  • It exposes only what the component needs: state and actions.

3) Using the composable in a component

vue

<script setup lang="ts"> import { useSearchableList } from '@/composables/useSearchableList' // import { api } from '@/services/api' interface Item { id: number name: string } const { searchTerm, filteredItems, isLoading, error, reload } = useSearchableList<Item, { search: string }>({ buildParams: (search: string) => ({ search }), fetchFn: async ({ search }) => { // const response = await api.get<Item[]>('/items', { params: { search } }) // return response.data // simplified example: return [ { id: 1, name: `Item filtered by: ${search}` } ] }, immediate: true }) </script>

<template> <input v-model="searchTerm" placeholder="Search..." /> <button @click="reload">Reload</button>

Loading...

{{ error }}

  • {{ item.name }}

</template>

You can reuse the same composable on another screen by changing only the Item type and the fetchFn function.

5.3. Composable best practices and pitfalls

Modern dashboard interface with a data table, search bar, status filters, and pagination controls.
Modern dashboard interface with a data table, search bar, status filters, and pagination controls.

Best practices:

  • Give each composable one clear responsibility:
  • useSearchableList → searching lists.
  • usePagination → pagination.
  • useAsyncData → asynchronous request state.

  • Do not try to “mirror” the entire component inside a composable:

  • Composables are building blocks that the component combines.

  • Avoid composables that know too much about the UI:

  • Leave text, labels, colors, and layout to components.
  • Composables should handle state and rules.

Trade-offs with TypeScript:

  • Composables become somewhat more verbose (interfaces and generics).
  • In return, TypeScript helps to:
  • Document parameters and return values.
  • Prevent misuse (by typing fetchFn, state, and so on).

6. Real case: organizing a “messy” component with the Composition API

6.1. The component “before”: Options API with mixed responsibilities

An DashboardList.vue component using the Options API could look like this:

ts import { defineComponent } from 'vue' // import { api } from '@/services/api'

interface Item { id: number name: string }

export default defineComponent({ data() { return { items: [] as Item[], searchTerm: '', statusFilter: 'all', currentPage: 1, pageSize: 20, totalItems: 0, isLoading: false, error: null as string | null } }, computed: { filteredItems(): Item[] { // filter by searchTerm and status return this.items }, paginatedItems(): Item[] { // slice filteredItems according to currentPage/pageSize return this.filteredItems } }, methods: { async fetchItems(): Promise<void> { this.isLoading = true this.error = null

  try {
    // const response = await api.get('/items', {
    //   params: {
    //     search: this.searchTerm,
    //     status: this.statusFilter,
    //     page: this.currentPage,
    //     pageSize: this.pageSize
    //   }
    // })
    // this.items = response.data.items
    // this.totalItems = response.data.total
  } catch (err) {
    this.error = 'Error loading items'
  } finally {
    this.isLoading = false
  }
},
handleSearchChange(): void {
  this.currentPage = 1
  void this.fetchItems()
},
handleStatusChange(): void {
  this.currentPage = 1
  void this.fetchItems()
},
goToPage(page: number): void {
  this.currentPage = page
  void this.fetchItems()
}

}, watch: { searchTerm() { this.handleSearchChange() }, statusFilter() { this.handleStatusChange() } }, created() { void this.fetchItems() } })

It works, but:

  • The logic for search, filters, pagination, loading/errors, and requests is tightly coupled.
  • Reusing pagination in another component requires copying much of the code.
  • Understanding the full flow requires moving between data, computed, methods, watch, and created.

6.2. Mentally migrating to the Composition API

First step: still without composables, simply reorganize the code by feature in <script setup>.

vue

<script setup lang="ts"> import { ref, computed, watch, onMounted } from 'vue' // import { api } from '@/services/api' interface Item { id: number name: string } // --- Base state --- const items = ref<Item[]>([]) const totalItems = ref<number>(0) const isLoading = ref<boolean>(false) const error = ref<string | null>(null) // --- Search and filters --- const searchTerm = ref<string>('') const statusFilter = ref<string>('all') // --- Pagination --- const currentPage = ref<number>(1) const pageSize = ref<number>(20) // --- Derived values --- const filteredItems = computed<Item[]>(() => { // local filtering by searchTerm/status, if necessary return items.value }) const paginatedItems = computed<Item[]>(() => { const start = (currentPage.value - 1) * pageSize.value const end = start + pageSize.value return filteredItems.value.slice(start, end) }) // --- Request --- async function fetchItems(): Promise<void> { isLoading.value = true error.value = null try { // const response = await api.get('/items', { // params: { // search: searchTerm.value, // status: statusFilter.value, // page: currentPage.value, // pageSize: pageSize.value // } // }) // items.value = response.data.items // totalItems.value = response.data.total } catch (err) { error.value = 'Error loading items' } finally { isLoading.value = false } } // --- Reactions --- watch([searchTerm, statusFilter], () => { currentPage.value = 1 void fetchItems() }) watch(currentPage, () => { void fetchItems() }) onMounted(() => { void fetchItems() }) </script>

Even without extracting anything, each responsibility is already easier to see.

6.3. Extracting genuinely useful composables

Now let’s separate two simple composables:

  • usePagination
  • useAsyncData

usePagination

ts // usePagination.ts import { ref, computed } from 'vue'

export interface UsePaginationOptions { pageSize?: number }

export function usePagination(options: UsePaginationOptions = {}) { const perPage = ref<number>(options.pageSize ?? 20) const currentPage = ref<number>(1) const totalItems = ref<number>(0)

const totalPages = computed<number>(() => { if (perPage.value === 0) return 0 return Math.ceil(totalItems.value / perPage.value) })

function goToPage(page: number): void { currentPage.value = page }

function resetPage(): void { currentPage.value = 1 }

return { currentPage, perPage, totalItems, totalPages, goToPage, resetPage } }

useAsyncData

ts // useAsyncData.ts import { ref } from 'vue'

export function useAsyncData<TData, TParams = void>( fetchFn: (params: TParams) => Promise<TData> ) { const data = ref<TData | null>(null) const isLoading = ref<boolean>(false) const error = ref<string | null>(null)

async function load(params: TParams): Promise<void> { isLoading.value = true error.value = null

try {
  data.value = await fetchFn(params)
} catch (err) {
  error.value =
    err instanceof Error ? err.message : 'Error loading data'
} finally {
  isLoading.value = false
}

}

return { data, isLoading, error, load } }

Rewriting the component with composables

vue

<script setup lang="ts"> import { ref, computed, watch, onMounted } from 'vue' import { usePagination } from '@/composables/usePagination' import { useAsyncData } from '@/composables/useAsyncData' // import { api } from '@/services/api' interface Item { id: number name: string } // --- Search and filters --- const searchTerm = ref<string>('') const statusFilter = ref<string>('all') // --- Pagination --- const { currentPage, perPage, totalItems, totalPages, goToPage, resetPage } = usePagination({ pageSize: 20 }) // --- Asynchronous request --- const { data: items, isLoading, error, load: loadItems } = useAsyncData<Item[], { search: string status: string page: number pageSize: number }>(async ({ search, status, page, pageSize }) => { // const response = await api.get('/items', { // params: { search, status, page, pageSize } // }) // totalItems.value = response.data.total // return response.data.items as Item[] // simplified example: totalItems.value = 100 return [ { id: 1, name: `Item ${search} - page ${page}` } ] }) // --- Derived values --- const filteredItems = computed<Item[]>(() => { const term = searchTerm.value.toLowerCase() return (items.value ?? []).filter(item => item.name.toLowerCase().includes(term) ) }) // --- Orchestration --- function reload(): void { void loadItems({ search: searchTerm.value, status: statusFilter.value, page: currentPage.value, pageSize: perPage.value }) } // --- Reactions --- watch([searchTerm, statusFilter], () => { resetPage() reload() }) watch(currentPage, () => { reload() }) onMounted(() => { reload() }) </script>

<template>

<input v-model="searchTerm" placeholder="Search..." />

<select v-model="statusFilter"> <option value="all">All</option> <option value="open">Open</option> <option value="closed">Closed</option> </select>

<button @click="reload">Reload</button>

Loading...

{{ error }}

  • {{ item.name }}
<nav> <button :disabled="currentPage === 1" @click="goToPage(currentPage - 1)" > Previous </button> {{ currentPage }} / {{ totalPages }} <button :disabled="currentPage === totalPages" @click="goToPage(currentPage + 1)" > Next </button> </nav>

</template>

The result:

  • The component is more focused on orchestrating search, filters, and pagination than on implementation details.
  • usePagination and useAsyncData can be reused on other screens.
  • If the pagination rule changes, you will probably only need to modify usePagination.

Trade-offs:

  • More files (composables) to maintain.
  • The team needs alignment on how to design composables.
  • In medium and large projects, the gain in clarity and reuse often makes the trade-off worthwhile.

7. When to use (or not use) Vue 3: Composition API in Practice

Cases where the Composition API shines

It tends to make a significant difference when:

  • You have medium or large components with several responsibilities (such as dashboards with search, filters, pagination, loading, errors…).
  • Business logic is complex and shared (domain rules, complex forms, integrations with external APIs).
  • The project is intended for long-term team maintenance.

Well-defined composables (usePagination, useAsyncData, and useSearchableList) create a shared vocabulary for the team.

Cases where the Options API still makes sense

The Options API remains completely valid when:

  • The component is simple and isolated:
  • A specialized button.
  • A card with one or two states.
  • The team is still learning Vue and does not feel real pain with the Options API.
  • You want to prototype something quickly without worrying as much about its future structure.

A balanced approach:

  • New, more complex code → start with <script setup> and the Composition API.
  • Small components → the Options API is still fine.
  • Refactor gradually, only what is causing pain today.

Gradual adoption strategies

  • Use <script setup> in new components, even without composables at first.
  • Create your first composables for concrete problems:
  • A loading/error pattern for API calls.
  • Pagination already shared by two screens.
  • When something is used in more than one component, consider extracting it into a composable.

Next Steps

If you made it this far, you already have enough foundation to use Vue 3: Composition API in Practice in your day-to-day work. Here are some suggested next steps:

  1. Choose a “painful” component in your current project
  2. Preferably something like a dashboard screen with search and filters.
  3. Rewrite it internally with <script setup lang="ts">, without changing the interface.

  4. Identify clear responsibilities

  5. Search, filters, pagination, loading/errors, and so on.
  6. Group related logic together (state + computed values + watchers + API calls).

  7. Extract your first composable

  8. Start small: usePagination or useAsyncData.
  9. Use it in at least two components to validate its design and types.

  10. Refine your team’s style

  11. Agree on conventions: composable names (useSomething), the internal structure of <script setup>, when to use ref versus reactive, how to type fetchFn, and so on.

  12. Connect with the community

  13. Bring your real-world cases, questions, and the patterns you discover:

Did you enjoy this article?

Share it with your friends and help spread knowledge!