Choosing Vue Composition API: An Architecture Note on Scalable Component Logic
An architecture note on Vue's Composition API: requirements, a searchable-table composable, ref/computed trust boundaries, DevTools and type-check verification, failure modes, and design-change triggers.
14 Apr 2026, 11:13 UTC

The Problem: Options API Hits a Ceiling
Vue's Options API organizes code by lifecycle hook (data, methods, computed, watch). That works for small components, but as features grow, related logic fragments across several options. A single feature — say a searchable, paginated table with loading states — scatters across data (state), methods (fetch logic), computed (derived filters) and watch (URL sync). Adding TypeScript types means maintaining separate interfaces, and testing the logic requires mounting the whole component.
Takeaway: the Composition API moves the boundary from "lifecycle phase" to "logical concern". You group reactive state, derived values and side effects by feature rather than by hook, which makes extraction, reuse and type-safe boundaries possible without rewriting the component tree.
Requirements Driving the Decision
- Colocation: logic for one feature stays together.
- Extractability: any reactive block can become a composable function.
- Type safety: props, emits and internal state infer from TypeScript without duplicate interfaces.
- Testability: pure reactive functions test in isolation; components test rendering only.
- Incremental adoption: mix with the Options API during migration.
Smallest Suitable Design: A Searchable-Table Composable
Instead of a monolithic component, split the feature into a composable plus a thin view layer. The example below assumes Vue 3.4+ with <script setup> and TypeScript; the API shown (ref, computed, watchEffect, onUnmounted) has been stable since Vue 3.0.
// composables/useSearchableTable.ts
import { ref, watchEffect, onUnmounted } from 'vue'
import type { Ref } from 'vue'
export interface TableState<T> {
items: Ref<T[]>
loading: Ref<boolean>
error: Ref<Error | null>
page: Ref<number>
pageSize: Ref<number>
query: Ref<string>
total: Ref<number>
}
export function useSearchableTable<T>(
fetchFn: (params: { page: number; pageSize: number; query: string }) => Promise<{ data: T[]; total: number }>
): TableState<T> & { refresh: () => Promise<void> } {
const items = ref<T[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
const page = ref(1)
const pageSize = ref(25)
const query = ref('')
const total = ref(0)
const load = async () => {
loading.value = true
error.value = null
try {
const res = await fetchFn({ page: page.value, pageSize: pageSize.value, query: query.value })
items.value = res.data
total.value = res.total
} catch (e) {
error.value = e as Error
items.value = []
total.value = 0
} finally {
loading.value = false
}
}
const stop = watchEffect(() => {
load()
})
onUnmounted(stop)
return { items, loading, error, page, pageSize, query, total, refresh: load }
}
The component then becomes a declarative template:
<script setup lang="ts">
import { useSearchableTable } from '@/composables/useSearchableTable'
import { api } from '@/services/api'
const { items, loading, error, page, pageSize, query, total, refresh } =
useSearchableTable<User>(api.users.list)
</script>
<template>
<input v-model="query" placeholder="Search..." />
<table v-if="!loading"> ... </table>
<div v-else>Loading...</div>
<Pagination :page="page" :page-size="pageSize" :total="total" @update:page="page = $event" />
<button @click="refresh" :disabled="loading">Refresh</button>
</template>
Note that watchEffect re-runs load() on every dependency change, including each keystroke in query. In production you would debounce the query or watch it explicitly; the version above is deliberately minimal so the boundary is easy to read.
Trust and Data Boundaries
Reactive refs as explicit boundaries
ref() wraps a primitive or object into a Ref<T> with a .value accessor. Inside <script setup>, refs auto-unwrap in templates, so query reads query.value implicitly. This creates a clear contract: anything mutable crosses a ref boundary, the composable returns plain refs, and the component consumes them with no hidden mutation.
Computed for derived state
Derived values should use computed(), which caches until a dependency changes. A page count would be:
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
This avoids a watcher that fires on every keystroke: the cache invalidates only when total or pageSize changes.
Props and emits as component contracts
defineProps and defineEmits are compiler macros — no import is required. They define the component boundary and are type-checked at compile time:
interface Props {
initialPage?: number
pageSizeOptions?: number[]
}
const props = withDefaults(defineProps<Props>(), {
initialPage: 1,
pageSizeOptions: () => [10, 25, 50, 100]
})
const emit = defineEmits<{
(e: 'page-change', page: number): void
(e: 'error', err: Error): void
}>()
Operational Checks
Verify reactivity in DevTools
Open Vue DevTools (browser devtools, Vue tab), select the component instance, and inspect the State panel, which lists each ref with its current value. Assigning a new value to a ref from the console should re-render the affected part of the template without a page reload. If it does not, the ref was probably destructured into a plain value somewhere along the way.
Confirm computed caching
Add a temporary log inside a computed getter:
const totalPages = computed(() => {
console.log('[computed] totalPages recalculated')
return Math.ceil(total.value / pageSize.value)
})
Type into the search input. The log should appear only when total or pageSize changes, not on every query keystroke. Remove the log afterwards.
Type-check the composable
Run vue-tsc --noEmit (often wired as npm run typecheck) from the project root. Errors in defineProps or in the generic inference of useSearchableTable<User> surface without running the app. This requires the TypeScript compiler and vue-tsc to be installed as dev dependencies.
Failure Modes
| Symptom | Likely cause | Mitigation |
|---|---|---|
| Template reads a stale value | Ref destructured without toRefs, losing reactivity | Return toRefs(state) from the composable; avoid plain destructuring of a reactive object |
| Computed fires too often | Dependency is an object ref mutated in place | Replace the whole object, or use shallowRef for large immutable blobs |
Type error on defineProps | Runtime default does not match the declared type | Use withDefaults with factory functions for arrays and objects |
| Work continues after unmount | watchEffect not stopped | Capture the stop handle it returns and call it in onUnmounted |
| Nested change not tracked | Nested property mutated on a shallowRef object | Prefer ref for deep reactivity; reserve shallowRef for large immutable data |
Conditions That Would Change the Design
- Team unfamiliar with the Composition API: stay on the Options API for consistency and migrate one feature at a time.
- Heavy reliance on
thisin mixins: the Composition API has no mixin equivalent; rewrite those as composables first. - Thousands of component instances:
shallowRefplus manualtriggerRefreduces tracking overhead but adds complexity. - Vue 2 compatibility required: the Composition API backport exists but lacks
<script setup>and the compiler macros. - Pinia already adopted: move server-state logic into stores and keep only local UI state in composables.
Migration Checklist (If Refactoring)
- Identify a single feature cluster inside a large component.
- Extract its
data,computed,methodsandwatchinto auseXfunction that returns refs and computeds. - Replace the component's internal logic with the composable call.
- Add
definePropsanddefineEmitswith TypeScript interfaces. - Run the type-check and unit-test the extracted composable in isolation.
- Verify reactivity in DevTools for the updated component.
Practical Verification
After adopting the pattern, compare before and after on a few measurable points:
- Bundle size: composables tree-shake, so unused exports drop out.
- Test count: unit tests for composables can replace some component integration tests.
- Refactor time: moving a feature to another component becomes an import plus a template change.
- Type errors caught: CI fails on prop or emit mismatches before deploy.
If none of these improve after two or three features, reconsider the abstraction level — the composables may be too granular or too broad. This article describes documented Vue behaviour and a design pattern, not a benchmarked result; treat the metrics as things to measure in your own project rather than as expected outcomes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.