Structuring the Vue Composition API Cleanly Instead of Setup Chaos
AI generated
<v/>
{ }
Vue 3 · Composition API · script setup · Architecture
Structuring the Vue Composition API cleanly
instead of setup chaos

The Composition API gives developers maximum freedom when structuring components, and that is exactly the problem. Without clear conventions, setup() functions grow into unreadable blocks of a hundred lines with mixed logic. This guide shows how to structure the Vue Composition API so components stay maintainable, even as they grow complex.

12 min read script setup · defineProps · defineEmits · useTemplateRef Vue 3.x · TypeScript · Volar

1. The setup chaos problem: when freedom becomes a trap

The Vue Composition API was developed as an answer to the scaling problems of the Options API. In the Options API, logic was organized by type: all data properties together, all methods together, all computed properties together. The problem: in a complex component with five different features, all five features were scattered across the sections. Understanding a single feature required constant jumping between sections.

The Composition API solves this problem by allowing logic to be grouped by feature instead of by type. But without conventions, a new problem arises: all degrees of freedom get used, and the result is a setup() function in which fetch logic, form validation, event handling and DOM manipulation are mixed without structure. That is worse than the Options API, because at least the type separation imposed some kind of structure. Setup chaos is the most common Composition API quality problem in real Vue 3 projects.

The solution is not less freedom but more convention. Clear rules for the order of declarations in script setup, for extracting into composables once a certain complexity threshold is reached, and for separating initialization, reactivity and interaction logic turn the freedom of the Composition API from a hazard into an advantage.

2. script setup: the modern Composition API syntax

<script setup> is the most compact and recommended way of writing the Vue Composition API for single file components. Everything declared at the top level of a <script setup> block, refs, computeds, functions, imported components, is automatically available in the template without an explicit return statement. This eliminates the most common boilerplate of the setup() function: returning every template binding.

Imported components in <script setup> no longer need to be registered; they are available directly in the template. The same applies to external composables and helper functions used in templates. defineProps() and defineEmits() are compiler macros that declare props and emits in a type-safe way without needing to be imported. The result is less boilerplate and better TypeScript integration than the classic setup() function combined with defineComponent().

An important aspect of <script setup>: the code runs once per component definition, not once globally. Unlike module-scope code, <script setup> code runs freshly for every component instance. That is the correct behavior for reactive state. Anyone who genuinely needs initialization code that runs only once uses a plain <script> block without the setup attribute in the same single file component; both blocks can coexist.


<!-- ProductCard.vue, script setup with clear structural zones -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useCartStore } from '@/stores/useCartStore'
import { useProductDetails } from '@/composables/useProductDetails'

// --- Props & Emits (always first) ---
const props = defineProps<{
  productId: number
  showActions?: boolean
}>()

const emit = defineEmits<{
  added: [productId: number]
  removed: [productId: number]
}>()

// --- Store Access ---
const cart = useCartStore()
const router = useRouter()

// --- Feature: Product Data (extracted composable) ---
const { product, loading, error } = useProductDetails(props.productId)

// --- Feature: Cart Interaction (local, simple enough to stay here) ---
const isInCart = computed(() => cart.contains(props.productId))

function addToCart() {
  cart.add(props.productId)
  emit('added', props.productId)
}

function goToDetail() {
  router.push(`/products/${props.productId}`)
}
</script>

3. The zone method: separating responsibilities in setup()

A proven convention for structuring <script setup> blocks is the zone method: declarations are arranged in semantically separated zones marked by comment dividers. The order follows a logic: what comes from outside first (props, emits), then what comes from the system (router, stores, inject), then reactive state, then derived values (computed), then effects (watch), then lifecycle hooks, then methods. This order mirrors the data flow of the component.

The zone method is not a strict rule but a convention that teams can adapt. What matters is consistency: when a developer navigates to a component, they know immediately where to find props, where computed properties are defined and where event handlers live. This predictability significantly reduces cognitive load when reading code. An ESLint plugin such as eslint-plugin-vue can enforce certain ordering rules automatically and warn on deviations.

Comment dividers such as // --- Feature: Pagination --- are the more natural alternative to type ordering when grouping by feature. For simple components, a type order is enough. For components with several clearly separated features, feature grouping is easier to follow: all refs, computeds, methods and watches belonging to pagination sit together. That makes it easier to understand a feature and to extract it once the component grows too large.

4. Composable extraction: when setup() code should be moved out

The most important decision when structuring the Vue Composition API is when logic should be moved into a composable. Three signals indicate that extraction is due: first, when a group of logic takes up more than twenty lines in setup() and has a clear, isolable responsibility. Second, when the same group of logic is also needed in another component. Third, when the group of logic is worth testing in isolation; testability is easier when logic is isolated in its own composable.

The extraction pattern is mechanical: move the group of logic into a new file under src/composables/, export it as a useX function, pass in the props and emits it needs as parameters. In the component, call the composable and destructure its return value. The component immediately becomes more readable, and the composable is independently testable. Extracting too early, for very simple, one-off logic, creates unnecessary indirection. Extracting too late lets setup() grow into an unreadable monolith function.


// BEFORE: setup chaos, 60 lines of mixed concerns in one function
// src/components/ProductList.vue (problematic version)

// All of this mixed together in script setup:
// fetch logic (15 lines), pagination (12 lines),
// filter state (10 lines), sort logic (8 lines), error handling (5 lines)

// AFTER: extracted into composables, each responsible for one thing
// src/components/ProductList.vue (clean version)

import { useProductFetch } from '@/composables/useProductFetch'
import { usePagination } from '@/composables/usePagination'
import { useProductFilter } from '@/composables/useProductFilter'
import { useProductSort } from '@/composables/useProductSort'

const { products, loading, error, refetch } = useProductFetch()
const { sortedProducts } = useProductSort(products)
const { filteredProducts, activeFilter, setFilter } = useProductFilter(sortedProducts)
const { paginatedProducts, currentPage, totalPages, nextPage } = usePagination(filteredProducts)

// script setup is now 15 lines, each line is meaningful and readable
// Each composable is independently testable
// Each composable can be reused in other list components

5. defineProps, defineEmits and typing in script setup

defineProps<T>() with a TypeScript generic is the preferred typing method for props in the Composition API. It provides full TypeScript inference without runtime overhead, because the Vite/Vue compiler extracts the prop types from the generic and generates the runtime props definition. The advantage over defineProps({ ... }) with runtime validation: TypeScript types are more expressive than runtime validators and surface errors at compile time rather than only at runtime.

withDefaults(defineProps<T>(), { ... }) is the pattern for props with default values in the type-based API. It combines full TypeScript typing with explicit default values. defineEmits<T>() with an interface that maps emit names to argument type tuples, such as { added: [productId: number] }, enables type-safe emit calls where TypeScript checks the arguments. That prevents an entire class of bugs that used to be hard to find in the Options API and without TypeScript: wrong argument types in emit calls.

6. Template refs and useTemplateRef in the Composition API

Template refs in the Composition API are declared with const el = ref<HTMLElement | null>(null) and bound via ref="el" in the template. In script setup, the ref name is automatically the variable name, with no string lookup as in the Options API. Vue 3.5 introduced useTemplateRef('name') as a type-safe alternative that derives the DOM element type directly from the generic parameter and has null safety built in.

The most common error pattern with template refs in the Composition API is accessing el.value outside of onMounted. Before the mount, the DOM element is not available and el.value is null. Every access to a template ref must happen either inside onMounted, in a watch on the ref itself with { immediate: false }, or with an explicit null check. Template refs bound to child components return the component instance; only properties exposed with defineExpose are accessible.

7. defineExpose: what should be public and what should not

In script setup components, internal state is private by default; parent components cannot access properties of a child component via a template ref unless they were explicitly released with defineExpose(). This is a deliberate design decision of the Composition API: component encapsulation is the default, opening up is explicit. This prevents parent components from reaching directly into a child's state and thereby creating tight coupling.

defineExpose({ reset, validate, focus }) releases exactly the methods that a parent component is allowed to call via a ref. This is the pattern for reusable form components, dialogs and input components that need to be controlled programmatically by parents. The exposed properties should be minimal and stable; every exposed property is a public API that affects every consumer when it changes.


<!-- InputField.vue, defineExpose for controlled parent access -->
<script setup lang="ts">
import { ref } from 'vue'

defineProps<{ label: string; modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

const inputRef = ref<HTMLInputElement | null>(null)
const hasError = ref(false)
const errorMessage = ref('')

function focus() {
  inputRef.value?.focus()
}

function validate(): boolean {
  // validation logic
  hasError.value = !inputRef.value?.validity.valid
  errorMessage.value = hasError.value ? 'Required field' : ''
  return !hasError.value
}

function reset() {
  hasError.value = false
  errorMessage.value = ''
  emit('update:modelValue', '')
}

// Only expose what parents legitimately need, keep internals private
defineExpose({ focus, validate, reset })
</script>

8. Breaking up large components: when splitting is mandatory

The rule of thumb for breaking up components in the Composition API is simpler than in the Options API: if a component contains more than three clearly separable feature groups, or if the template has more than fifty lines, splitting is due. That is because the Composition API makes extracting logic into composables very easy; the barrier to breaking things up is low, and the gains in readability and testability show up immediately. A product listing page containing filters, search, pagination and product card rendering should be split into a container component with composables and three to four presenter components.

The container/presenter pattern works especially well with the Composition API: the container component holds all the composable calls and state management but little template. The presenter components receive props and emit events but have little or no state of their own. That makes presenter components trivially testable; they are pure mapping functions from props to templates. And the container component is also well testable thanks to its composables.

9. Options API vs. Composition API: structure comparison

The structural comparison between the Options API and the Composition API shows where each approach has its strengths. The Options API provides structure through enforced type separation, but that structure scales poorly with component complexity. The Composition API provides freedom and feature cohesion, but scales well only with conventions.

Aspect Options API Composition API Recommendation
TypeScript integration Limited (this typing is difficult) Excellent Composition API
Logic reuse Only via mixins Composables Composition API
Entry barrier Low Medium (reactivity model) Options API for beginners
Scaling with complexity Poor (feature fragmentation) Good with conventions Composition API
Testability of logic Difficult (this context) Easy (composables) Composition API

The Vue Composition API is clearly the recommended choice for new Vue 3 projects. Existing Vue 2 projects migrating to Vue 3 can keep the Options API or migrate step by step. Both APIs coexist without issue. The Composition API only unfolds its full potential once composables are used consistently and conventions for setup() structure are established.

Mironsoft

Vue 3 architecture, code review and Composition API training

Composition API architecture for your team?

We review existing Vue 3 components for setup chaos, establish conventions for script setup and train your team in clean Composition API design, with concrete refactoring examples from your own code.

Code review

Identify setup chaos, assess composable extraction potential and propose conventions

Refactoring

Refactor monolithic setup() functions into clean composable structures

Team training

Document Composition API best practices for your team and teach them in a workshop

10. Summary

The Vue Composition API is more powerful than the Options API, but only if its freedom is tamed by conventions. script setup is the recommended syntax for new components: less boilerplate, better TypeScript integration, template bindings automatically available. The zone method structures script setup into semantically ordered areas: props, emits, stores, reactive state, computed, watch, lifecycle, methods. Extract into composables once a logic group exceeds twenty lines or once reuse is needed.

defineProps<T>() and defineEmits<T>() with TypeScript generics are the type-safe, compiler-optimized declaration methods. defineExpose() explicitly opens up what parent components legitimately need via ref; everything else stays private. Component splitting following the container/presenter pattern keeps components readable even as features grow. The Composition API only unfolds its full potential as a complete system: script setup, composables, TypeScript and clear conventions together.

Structuring the Vue Composition API: the essentials at a glance

script setup zones

Props to emits to stores to state to computed to watch to lifecycle to methods. Consistent order for predictability.

Composable threshold

More than 20 lines in a logic group, reuse needs or testability are signals for immediate extraction into a composable.

TypeScript integration

defineProps<T>(), defineEmits<T>() with generics for full type inference without runtime overhead.

defineExpose minimal

Only expose what parents legitimately need. Every exposed property is a public API. Keep internals private.

11. FAQ: Structuring the Vue Composition API

1script setup vs. setup()?
script setup is syntactic sugar: less boilerplate, no return, imported components automatically available, better TypeScript. For new components, always use script setup.
2When to extract a composable?
20+ lines in a logic group, reuse needs or testability as a signal. Too early: unnecessary indirection. Too late: setup chaos.
3Zone method?
Props to emits to stores to state to computed to watch to lifecycle to methods. Consistency across the team is what matters.
4defineProps with TypeScript?
defineProps<T>() with an interface generic. The compiler generates the runtime definition. withDefaults() for default values.
5When to defineExpose?
When parents need to call focus(), validate(), reset() via a ref. Internals stay private. Every exposed property is a public API.
6Template ref outside onMounted?
Do not access it, el.value is null before mount. Only inside onMounted, in a watch without immediate, or with an explicit null check.
7Mixing Options API and Composition API?
Across different components without issue. Not recommended within a single component. Migrate step by step, component by component.
8Container/presenter pattern?
Container: composables and state, little template. Presenter: props and events, no state. Presenter trivially testable, container well isolated and testable via composables.
9Feature vs. type grouping?
Feature grouping when there are several separable features. Type order for simple components. Comment dividers mark zones.
10Preventing setup chaos?
Regular composable extraction, template splitting at 50+ lines, eslint-vue ordering rules, code reviews with a focus on setup() structure.