Why composition matters more than options
By Day 4, you've likely written a few Vue components. Whether you've been organizing logic by lifecycle methods, data properties, and computed values, or you've experimented with the newer approach, there's a critical shift happening in how we structure Vue applications. The Vue Composition API tutorial: what developers should know starts with understanding that organizing logic by feature, not by option, fundamentally changes how we build maintainable, scalable components.
The Options API grouped related concepts by their type: all data went into data(), all methods into methods, all computed properties into computed. While this worked, it forced you to jump between sections when building a feature. The Composition API lets you keep related logic together—state, computed values, watchers, and side effects all in one place. This matters when you're debugging, refactoring, or bringing a new engineer onto the team.
Script setup — The recommended way to write components
If you're starting a new Nuxt or Vue project today, <script setup> is the way forward. It's syntactic sugar over the Composition API that removes boilerplate and makes your components cleaner.
Here's what changed: instead of wrapping everything in a setup() function and explicitly returning what you want exposed to the template, you write top-level code that's automatically available. Variables, functions, and imports are exposed by default.
<script setup lang="ts">
import { ref, computed } from 'vue'
// Define reactive state
const count = ref(0)
// Define a method
const increment = () => {
count.value++
}
// Computed property
const doubled = computed(() => count.value * 2)
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Doubled: {{ doubled }}</p>
<button @click="increment">Increment</button>
</div>
</template>Notice there's no return statement. The template automatically has access to count, doubled, and increment. TypeScript support is built in—your IDE will catch type errors before runtime. This is standard practice in production Nuxt applications because it reduces noise and keeps components readable at a glance.
When would you not use <script setup>? Rarely. You might reach for the traditional setup() function if you need more control over what gets exposed or if you're working with a legacy codebase. For new components, assume <script setup> unless there's a specific constraint.
Reactive state — ref for values
Reactive state is the foundation. In the Options API, you'd use the data() function. In the Composition API, you use ref for individual values and reactive for objects. We'll focus on ref because it's more flexible and works well with TypeScript.
When you call ref(0), Vue wraps that value in a reactive object. Inside your script, you access it via .value. Inside templates, Vue automatically unwraps it so you just write count, not count.value.
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const user = ref({ name: 'Alice', email: 'alice@example.com' })
const handleClick = () => {
count.value++
user.value.name = 'Bob'
}
</script>
<template>
<button @click="handleClick">
Clicks: {{ count }} | User: {{ user.name }}
</button>
</template>In production applications—particularly in e-commerce platforms tracking cart items or user sessions—you'll use ref for values that change over time. A real-world example: managing a shopping cart where adding or removing items must trigger reactivity across the entire component tree.
One thing to watch: always use .value in your script. It's easy to forget and assign to the wrapper instead of the actual value. Your linter should catch this, but it's a common stumbling block when you're new to ref.
Computed — Derived values that cache
Computed properties derive their value from other reactive data. They cache the result and only recompute when their dependencies change. This is both a performance optimization and a way to keep your template clean.
Why not just use a method? Methods run every time they're called. Computed properties cache. If you have an expensive calculation—say, filtering a list of 10,000 items—computing it only when the list changes, rather than on every render, makes a measurable difference.
<script setup lang="ts">
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'Laptop', price: 1200, category: 'electronics' },
{ id: 2, name: 'Desk', price: 300, category: 'furniture' },
{ id: 3, name: 'Monitor', price: 400, category: 'electronics' },
])
const selectedCategory = ref('electronics')
// Derived value: only updates when items or selectedCategory changes
const filteredItems = computed(() => {
return items.value.filter(item => item.category === selectedCategory.value)
})
const totalPrice = computed(() => {
return filteredItems.value.reduce((sum, item) => sum + item.price, 0)
})
</script>
<template>
<div>
<select v-model="selectedCategory">
<option value="electronics">Electronics</option>
<option value="furniture">Furniture</option>
</select>
<ul>
<li v-for="item in filteredItems" :key="item.id">
{{ item.name }} - ${{ item.price }}
</li>
</ul>
<p><strong>Total: ${{ totalPrice }}</strong></p>
</div>
</template>Notice that filteredItems depends on both items and selectedCategory. When either changes, Vue automatically recomputes. The template references filteredItems, not the function—you read it like a property, not a method call.
In a real SaaS platform, computed properties shine when transforming API responses. You fetch user data once, then compute derived fields: full name from first and last name, formatted dates, permission flags based on roles. Keep your computed properties pure—no side effects, no API calls. That's what watch is for.
Watch — Side effects when data changes
Watchers let you run code when reactive data changes. This is where you handle side effects: API calls, analytics tracking, local storage updates, or triggering other logic.
There are three main ways to watch: watch() for fine-grained control, watchEffect() for automatic dependency tracking, and lifecycle hooks like onMounted for one-time setup.
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'
const searchQuery = ref('')
const results = ref([])
const isLoading = ref(false)
// Watch a single source
watch(searchQuery, async (newValue) => {
if (newValue.length < 2) {
results.value = []
return
}
isLoading.value = true
try {
const response = await fetch(`/api/search?q=${newValue}`)
results.value = await response.json()
} finally {
isLoading.value = false
}
})
// watchEffect tracks dependencies automatically
watchEffect(() => {
console.log(`Search query changed: ${searchQuery.value}`)
})
</script>
<template>
<div>
<input v-model="searchQuery" placeholder="Search..." />
<p v-if="isLoading">Loading...</p>
<ul>
<li v-for="result in results" :key="result.id">
{{ result.title }}
</li>
</ul>
</div>
</template>In this example, every time searchQuery changes, the watcher fires an API request. We debounce in production (using a library like lodash-es), but the pattern is clear: reactive data triggers side effects.
Key differences: watch() lets you specify exactly what to watch and gives you both the new and old values. watchEffect() automatically tracks dependencies but doesn't give you the old value. For most production use, watch() is more explicit and easier to reason about. Use watchEffect() when you have multiple interdependent reactive values and want Vue to figure out dependencies for you.
Remember to clean up: if your watcher starts a timer, sets up a WebSocket connection, or subscribes to an event, call the returned stop function in onBeforeUnmount to prevent memory leaks.
Common mistakes
Forgetting .value in script: The most common mistake. You define const count = ref(0), then try to do count++ instead of count.value++. Your linter should warn you, but it's worth being intentional about it.
Putting side effects in computed properties: Computed properties should be pure. Don't fetch data, log to analytics, or modify external state inside them. That belongs in watch or a method. Computed properties are for transformations only.
Watching objects without deep option: If you have a complex object and you mutate a nested property, the watcher might not fire unless you pass { deep: true }. For performance reasons, use watch on specific properties instead when possible.
Not handling async watcher cleanup: If your watcher starts an async operation (like a fetch), and the component unmounts before it completes, you'll get a memory leak warning. Use the onBeforeUnmount lifecycle hook or the invalidate callback passed to the watcher to clean up.
Key takeaways
<script setup>is the modern way to write Vue components. It reduces boilerplate and makes your code more readable.- Use
reffor reactive state. Remember to access it via.valuein your script, but not in templates. - Computed properties cache derived values and only recompute when dependencies change. Use them to keep templates clean and improve performance.
- Use
watchto handle side effects when reactive data changes. Keep watchers focused on a single concern. - The Composition API encourages organizing code by feature, not by type. Group related state, computed values, and watchers together in your component.






Comments
Loading comments…