Organizing Vue 3 Component Logic with setup(): A Practical Guide
Learn how Vue 3's setup() function groups reactive state, computed values, and methods by feature, and when extracting composables makes sense.
28 Jun 2026, 06:37 UTC

You are building a product search component. The query string lives in data, the debounced fetch lives in watch, and the result formatting lives in computed. Six months later, adding autocomplete means touching four separate blocks in the same file. The Options API organizes code by option type, not by feature.
Vue 3's setup() function lets you organize component logic by feature. It runs once per component instance before the component is created, and whatever object you return becomes available to the template. This keeps related reactive state, computed values, and methods together, and makes it easier to extract them into reusable composables when a feature grows.
The fragmentation problem
In the Options API, a single feature is scattered across the component definition:
data()holds the query stringwatchtriggers the API callmethodscontains the fetch handlercomputedformats the results
Following the feature means scrolling. Extracting it means copying blocks into mixins, which create property-name collisions and obscure data flow.
What setup() changes
setup() is a component option that runs before the instance is created. Because it runs before this exists, you cannot access instance properties inside it. Instead, you declare reactive variables with ref or reactive, define functions, and return an object. Vue exposes those returned properties to the template.
This removes the need for this entirely. Arrow functions behave predictably, and TypeScript can infer types more reliably because there is no dynamic this context to resolve. Vue also offers <script setup> as compiler sugar for this pattern; the explicit setup() function shown here makes the returned bindings visible.
Worked example: extracting a search feature
Suppose you have a ProductSearch.vue component. Initially, you might write everything inside setup():
<script>
import { ref, watch } from 'vue'
export default {
setup() {
const query = ref('')
const results = ref([])
const isLoading = ref(false)
watch(query, async (newQuery) => {
if (!newQuery) {
results.value = []
return
}
isLoading.value = true
try {
const res = await fetch(`/api/products?q=${encodeURIComponent(newQuery)}`)
results.value = await res.json()
} finally {
isLoading.value = false
}
})
return { query, results, isLoading }
}
}
</script>
<template>
<input v-model="query" placeholder="Search products..." />
<p v-if="isLoading">Loading...</p>
<ul>
<li v-for="item in results" :key="item.id">{{ item.name }}</li>
</ul>
</template>
To verify this behavior, run the development server from your project root with npm run dev (requires Node.js and installed dependencies). Expected check: the terminal prints a local URL, the browser renders the component, and typing in the input fires a network request that populates the list. Risk: if the port is occupied, Vite will automatically try the next available port.
As the feature grows—adding pagination, caching, or error handling—the setup() body becomes long. Extract it into a composable file:
// composables/useProductSearch.js
import { ref, watch } from 'vue'
export function useProductSearch(endpoint) {
const query = ref('')
const results = ref([])
const isLoading = ref(false)
watch(query, async (newQuery) => {
if (!newQuery) {
results.value = []
return
}
isLoading.value = true
try {
const res = await fetch(`${endpoint}?q=${encodeURIComponent(newQuery)}`)
results.value = await res.json()
} finally {
isLoading.value = false
}
})
return { query, results, isLoading }
}
Then the component becomes:
<script>
import { useProductSearch } from './composables/useProductSearch'
export default {
setup() {
const { query, results, isLoading } = useProductSearch('/api/products')
return { query, results, isLoading }
}
}
</script>
<template>
<input v-model="query" placeholder="Search products..." />
<p v-if="isLoading">Loading...</p>
<ul>
<li v-for="item in results" :key="item.id">{{ item.name }}</li>
</ul>
</template>
The template stays identical. The component now reads as a composition of features rather than a pile of options.
Trade-offs and limitations
Mental model shift. Developers moving from the Options API must adjust to returning bindings instead of assigning them to this. Template refs, for example, are not populated until after mount, so accessing them inside setup() requires a different pattern than this.$refs.
Composable scoping. When composables use provide/inject, naming collisions become possible if two composables inject the same key. Use unique string or Symbol keys, or namespace them (e.g., 'search:state'), to avoid silent failures.
Not everything belongs in a composable. One-off component-specific UI state does not need extraction. Premature composable creation adds indirection without payoff. Extract when the same logic appears in a second component, or when a single setup() exceeds roughly 50–80 lines and mixes unrelated concerns.
Lifecycle timing. setup() runs before beforeCreate. If you need to access emit or attrs, use the context argument (setup(props, context)) rather than trying to reach for this.
Actionable closing
These examples assume Vue 3.x. The Composition API, including setup(), is available in Vue 3; Vue 2 supports it only via the @vue/composition-api plugin.
Pick one feature in an existing Options API component—search, form validation, or a WebSocket connection—and rewrite it inside setup(). Keep the template unchanged. Once it works, move the logic into a separate file as a composable and import it back. If the component still behaves identically, you have confirmed the extraction. Start with features that already repeat across components; leave one-off state inline.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.