Vue 3 in Practice: Composables, Typed Contracts and Proportional State
How Vue 3's Composition API, typed props and emits, Pinia and incremental adoption let an app grow only as complex as it needs, and when Vue is the wrong choice.
This article is published in English.
Sources: Vue.js Is the Frontend Framework More Teams Should Be Using
When a team picks React, nobody asks for a justification; when a team picks Vue, someone almost always does. That reaction says more about habit than about the framework, because Vue 3 is a mature option with official routing and state management, first-class TypeScript support and a full-stack route through Nuxt. This article looks at Vue 3 through working code: how state and derived values read, how composables package behavior, how typed props and emits expose component contracts, how Pinia fits in, and where Vue is simply not the right call.
The recurring theme is proportion. Vue gives you enough structure for serious applications without turning every small feature into an architectural decision, and that property is easiest to judge on a real component rather than in a feature comparison.
A realistic first component: filtered search
Todo apps say little about a framework. A slightly more realistic test is a product search: an input, a product list and the subset that matches the query.
The logic below uses two primitives. ref creates reactive state, here the search text and the product array. computed derives a value from that state and caches it until one of its dependencies changes. Notice that the filter trims and lowercases the query once, returns the full list for an empty query, and only then filters.
import { computed, ref } from 'vue'
interface Product {
id: number
name: string
price: number
}
const search = ref('')
const products = ref<Product[]>([
{ id: 1, name: 'Mechanical Keyboard', price: 120 },
{ id: 2, name: '4K Monitor', price: 450 },
{ id: 3, name: 'Wireless Mouse', price: 60 }
])
const filteredProducts = computed(() => {
const query = search.value.toLowerCase().trim()
if (!query) {
return products.value
}
return products.value.filter(product =>
product.name.toLowerCase().includes(query)
)
})
The template binds to those values directly. v-model keeps the input and search in sync in both directions, v-for renders one item per filtered product, and :key gives Vue a stable identity for each row so it can update the list efficiently.
<input
v-model="search"
type="search"
placeholder="Search products..."
/>
<ul>
<li
v-for="product in filteredProducts"
:key="product.id"
>
{{ product.name }} - ${{ product.price }}
</li>
</ul>
There is very little ceremony. The state is visible, the derived value is visible, and the markup that uses them is visible. You can read it top to bottom without first learning a vocabulary of framework-specific mechanisms. That can sound like a minor virtue, but as a codebase grows, how quickly someone can understand an unfamiliar file becomes one of the most important things a framework influences.
One detail worth knowing: in a template, refs are unwrapped automatically, so you write search rather than search.value. In script code you still use .value, which is why the computed function reads search.value and products.value.
The Composition API: reuse behavior, not components
The biggest shift between older Vue and Vue 3 is the Composition API. The Options API grouped code by framework category (data, methods, computed, watchers), which scattered a single feature across several sections. The Composition API lets you group code by what the feature does.
Take a product page that needs to fetch data and track loading and error states. That behavior can move into a composable, a plain function whose name conventionally starts with use and which returns reactive state plus the functions that change it:
import { ref } from 'vue'
export interface Product {
id: number
name: string
price: number
}
export function useProducts() {
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function loadProducts(): Promise<void> {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`)
}
products.value = (await response.json()) as Product[]
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'Failed to load products.'
} finally {
loading.value = false
}
}
return {
products,
loading,
error,
loadProducts
}
}
A few things are deliberate here. loading is reset in finally, so it is cleared whether the request succeeds or fails. error is cleared before each attempt so a stale message does not linger. A non-2xx response is turned into an exception, because fetch only rejects on network failures, not on HTTP error statuses. And the catch block narrows err with instanceof Error before reading message, which is necessary because TypeScript types caught values as unknown.
The important distinction is what gets reused. You are not sharing a component with its markup; you are sharing behavior. Components stay focused on what the user sees, composables focus on how a feature works, and neither needs to become a global abstraction just so two screens can share some logic. One caveat: each call to useProducts() creates its own independent state. That is usually what you want, but if two components must see the same product list, you need shared state, which is where a store comes in later.
Giving every concern a natural home
A framework can be powerful in the sense that it offers many capabilities, or in the sense that it reduces the gap between what you intend and what you type. Vue tends toward the second.
Consider a requirement that sounds small: when a filter changes, reflect it in the URL, refetch the data, preserve pagination and show a loading indicator. There are several moving parts, and each one has an obvious owner in a sensible Vue architecture:
- the component renders the interface
- a composable implements the feature's behavior
- the router owns URL state
- an API client handles backend communication
- a store holds data only if it genuinely needs to be shared across the app
No single piece needs to know about everything. This separation is not exclusive to Vue, and it is achievable in any modern framework. What stands out is how naturally Vue's primitives push you toward it.
Single-file components: one place for one UI unit
Vue's single-file components combine template, script and styles in a .vue file. Critics sometimes treat that as a stylistic quirk, but the underlying idea is not "put everything in one file". It is that one UI unit should live in one place. What matters here is the component model rather than the file format.
The behavior of a simple disclosure toggle is a single boolean and a function that flips it:
import { ref } from 'vue'
const isOpen = ref(false)
function toggle(): void {
isOpen.value = !isOpen.value
}
The markup reads just as directly. @click wires the button to toggle, and v-if adds or removes the panel depending on isOpen:
<button type="button" @click="toggle">
Account Settings
</button>
<div v-if="isOpen">
Manage your account preferences.
</div>
The distance between state and UI is tiny, and it stays tiny. When you return to the feature months later to debug it, the relationship between the flag and what appears on screen is still obvious. Note that v-if removes the element from the DOM entirely; if the panel toggles very frequently or holds expensive content, v-show, which only hides it with CSS, can be the better choice.
Good component architecture is not about minimizing the number of files. It is about making related things easy to find.
TypeScript where the contracts are
A frontend application is full of contracts: props a component accepts, events it emits, values a composable returns, models an API client produces, state and actions a store exposes. When those contracts are implicit, refactoring a large codebase means relying on people remembering assumptions instead of reading them.
Vue 3 lets those contracts sit right next to the component logic. Inside <script setup>, defineProps and defineEmits are compiler macros that accept type-only declarations:
interface User {
id: number
name: string
email: string
}
const props = defineProps<{
user: User
}>()
const emit = defineEmits<{
updated: [user: User]
}>()
function save(): void {
emit('updated', props.user)
}
The props type says the component requires a user of type User. The emits type uses a tuple-style signature, meaning the updated event carries exactly one argument, a User. Calling emit with the wrong event name or payload is a type error. Anyone opening the file can see the component's public interface at a glance.
That is the part of TypeScript that pays off most in large frontends. Autocomplete and editor hints are pleasant, but explicit contracts are what make changes safe to reason about and refactors less risky.
Keep state as local as you can
A reliable way to make a frontend complicated is to put everything in global state: a store for the modal, a store for a form field, a store for the selected table row, a store for a transient search query. Before long, half the application lives inside one sprawling state layer where any code can change anything.
Vue supports a gentler progression:
- keep local state inside the component that owns it
- move reusable behavior into composables
- put genuinely shared state into a store
For that last step, Pinia is Vue's official store. The example below is an options-style store for a shopping cart. state returns the initial data, the total getter derives the cart sum from items and quantities, and the actions modify the list. addItem increases the quantity when the product is already in the cart, and otherwise pushes a copy of the item so the store does not share an object reference with the caller.
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
interface CartState {
items: CartItem[]
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({
items: []
}),
getters: {
total: (state): number =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
},
actions: {
addItem(item: CartItem): void {
const existing = this.items.find(
current => current.id === item.id
)
if (existing) {
existing.quantity += item.quantity
} else {
this.items.push({ ...item })
}
},
removeItem(itemId: number): void {
this.items = this.items.filter(
item => item.id !== itemId
)
}
}
})
The store is not the interesting part; the decision to create it is. A text input almost never needs global state. A cart that the header, product pages and checkout all read almost certainly does. Vue leaves room for that judgment instead of pushing every piece of state into the same container. Pinia also supports a setup-style syntax that uses ref and computed directly, which some teams prefer because it matches the way composables are written.
A growth path instead of an upfront commitment
Vue does not require you to decide the whole architecture on the first day. You start with Vue itself, add Vue Router when you need routing, add Pinia when you need application-wide state, and move to Nuxt when you want server rendering, server routes and a broader full-stack setup. A small application can stay small; a large one can gain structure as its requirements arrive.
That matters most for existing products. Vue can be mounted into part of a server-rendered application built with Django, Laravel, Rails or plain PHP without rebuilding the entire frontend. Perhaps only an analytics dashboard needs rich interactivity. Perhaps one configuration screen has outgrown server templates, or an editor needs heavy client-side behavior. Vue can own that region and leave everything else alone.
Not every frontend has to be an SPA
The ecosystem sometimes treats "frontend application" and "single-page application" as synonyms. They are not. Many products are better served by server-rendered pages with a few carefully chosen interactive islands, and Vue fits that model well.
For teams maintaining mature applications, this reframes migration entirely. The question stops being "how do we rewrite the product in Vue?" and becomes "where would Vue remove the most friction?". The second question leads to smaller, safer steps and earlier value.
Performance comes from architecture, not benchmarks
Vue performs well, but benchmark charts are a weak reason to choose any framework. Production applications are rarely slow because their framework lost a microbenchmark by a few milliseconds. They are slow because they render huge lists, ship oversized bundles, repeat network requests, keep too much state alive, or do expensive work in the wrong place.
Vue's model makes it easier to avoid those problems:
- components can stay small and focused
- state can stay local rather than triggering updates across the app
- features and routes can be lazy-loaded
- reusable logic can be isolated in composables
- global state can be limited to what truly belongs there
None of that makes performance automatic. It makes good performance easier to reason about, which is worth more than a benchmark win.
A mature ecosystem
Choosing Vue once meant accepting a smaller ecosystem. That argument is much weaker now. There is an official router, Pinia for application state, mature developer tooling, a wide selection of UI component libraries and Nuxt for teams that want a full application framework.
Together they cover a wide range: a single interactive widget, a large SPA, a server-rendered site or a full-stack product. You do not need to know which of these you are building before writing the first line of code, and that flexibility is a feature in its own right.
The less measurable argument: decisions follow need
The hardest benefit to quantify is how the day-to-day decisions feel. You open a component and understand it. You need reusable behavior, so you write a composable. State belongs to one component, so it stays there. Something truly needs sharing, so it moves into a store. The app needs routing, so you add the router. It grows beyond a client-side app, so you adopt Nuxt.
Each step responds to complexity that actually exists. You make architectural decisions because the product requires them, not because the framework demands them upfront. Over the life of a project, that difference compounds.
When Vue is the wrong choice
Vue is not automatically the right answer. An organization with a big React codebase, its own React component library, deep in-house React experience and recruiting geared to React engineers will probably lose more than it gains by migrating. Frameworks are never chosen in isolation; team skills, existing infrastructure, hiring and migration cost all weigh at least as much as the programming model.
Vue also does not protect you from bad engineering. It is entirely possible to build giant components, bloated global stores, needless layers of abstraction and slow pages in Vue, just as in any other framework. It offers pleasant defaults, not a substitute for judgment.
Proportion as a design principle
What stands out about Vue is its sense of proportion. A small problem gets a small solution. Reusable behavior becomes a composable. Shared state becomes a store only when it needs to. Navigation belongs to the router, a larger application can move to Nuxt, and TypeScript describes the boundaries without taking over the codebase.
None of this is revolutionary, and that is the point. A framework does not have to reinvent frontend development every few months; it has to make the existing problems easier to solve.
Key takeaways
- Vue 3's
refandcomputedkeep state and derived values explicit, which keeps components readable as they grow. - Composables share behavior rather than markup; each call creates independent state, so use a store when data must truly be shared.
- Type-only
definePropsanddefineEmitsmake a component's public contract visible and compiler-checked. - Escalate state deliberately: local first, composable next, Pinia only for genuinely global data.
- Vue can be adopted one interactive area at a time inside an existing server-rendered app, which makes it a low-risk option for mature products.
- Team expertise, existing code and hiring can outweigh any framework's ergonomics, so evaluate Vue in that context rather than on popularity or benchmarks.