Most Vue developers don’t notice when their codebase starts falling apart.
At first everything feels simple: a clean component, a small API call, a few reactive variables, a couple of watchers. Vue makes early progress feel effortless.
And then the project grows.
A new screen.
A new flow.
Another API call.
A bit of duplicated state “for now.”
A watcher added as a quick fix.
A temporary boolean to fix an edge case.
A composable created without a clear purpose.
A Pinia store that now holds everything because “it’s global anyway.”
Before you realize it, the app becomes unpredictable:
- components re-render for no reason,
- data changes in places you didn’t expect,
- bugs appear when two features accidentally depend on the same state,
- and removing old code feels terrifying because you’re not sure what it breaks.
This article explains why this happens, and more importantly, how to prevent it from happening again — even if you're still early in your Vue journey.
The real reason Vue apps get messy
Vue doesn’t force you into architecture.
It gives you freedom — sometimes too much freedom.
You can put state anywhere: inside components, inside composables, inside Pinia, inside random refs declared in the global scope. You can fetch data inside mounted(), inside composables, inside watchers, inside actions, or inside event handlers.
There are no strict rules, and that means junior developers often do what feels natural:
“I need data → I’ll put it here.”
“I need this flag → I’ll add it here.”
“I need to update something → let me add a watcher.”
This works perfectly… until features start interacting with each other.
Let’s look at an innocent example.
A small component that slowly becomes a monster
You start with this:
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const products = ref([])
onMounted(async () => {
products.value = await api.getProducts()
})
</script>
Nothing wrong here.
Then someone adds filtering:
const filters = ref({})
watch(filters, async () => {
products.value = await api.getProducts(filters.value)
})
Still fine.
Then another team member adds a “selected product”.
Then “draft product”.
Then pagination.
Then loading states.
Then error states.
Suddenly the component looks like this:
const products = ref([])
const filters = ref({})
const isLoading = ref(false)
const selected = ref(null)
const draft = ref({})
const error = ref(null)
watch(filters, async () => {
isLoading.value = true
try {
products.value = await api.getProducts(filters.value)
} catch (e) {
error.value = e
} finally {
isLoading.value = false
}
})
And you feel it:
you already lost control.
Why this happens: components are NOT meant to handle logic
A Vue component should be mostly declarative:
“What should this UI show based on the state?”
But junior developers often write components imperatively:
“What steps must I perform to make this work?”
When you put API calls, derived data, transformation logic, and UI state all in the same file — components become untestable, unreadable, and impossible to refactor.
The first step toward clean architecture is understanding this:
Components should display state, not manage it.
Let’s extract logic.
Moving logic into a composable (the right way)
Instead of stuffing all logic inside the component, we move it out:
// useProducts.ts
import { ref, watch } from 'vue'
import { api } from '@/api'
export function useProducts() {
const products = ref([])
const filters = ref({})
const isLoading = ref(false)
const error = ref(null)
const load = async () => {
isLoading.value = true
try {
products.value = await api.getProducts(filters.value)
} catch (e) {
error.value = e
} finally {
isLoading.value = false
}
}
watch(filters, load)
return { products, filters, isLoading, error, load }
}
Now the component becomes clean again:
<script setup lang="ts">
import { useProducts } from '@/composables/useProducts'
const { products, filters, isLoading, load } = useProducts()
</script>
The UI becomes declarative.
The logic becomes reusable.
And the complexity stops growing inside the component.
This is already a huge improvement — but it’s not enough.
When state becomes shared between screens
Here’s the next problem junior developers hit:
Two different components need the same data.
Example:
- Product list page
- Product details page
- Admin product editor
- Recommendations sidebar
- Cart preview
All of them need “products,” but in slightly different ways.
If each component creates its own instance of useProducts(), you end up duplicating:
- API calls
- transformations
- caching
- error handling
This is where people jump to Pinia — but often do so incorrectly.
Pinia is not a storage box — it’s a state owner
Many beginners treat Pinia as a place to dump everything:
export const useProductStore = defineStore('product', {
state: () => ({
products: [],
filters: {},
selected: null,
draft: {},
loading: false,
error: null,
// more and more...
})
})
This leads to a store that knows too much, does too much, and depends on too many features.
The correct approach is simpler:
Pinia should own state — and only the state that truly belongs to the domain.
For example, API filters are UI state → they belong in a composable or component.
But the data itself?
That belongs to the domain → it belongs in a store.
A clean, beginner-friendly store structure
Here’s how a clean store might look:
// product.store.ts
export const useProductStore = defineStore('product', {
state: () => ({
items: [] as Product[],
isLoaded: false,
}),
actions: {
async load() {
if (this.isLoaded) return
this.items = await api.getProducts()
this.isLoaded = true
}
},
getters: {
byId: (state) => (id: number) =>
state.items.find(p => p.id === id)
}
})
This is simple, clean, scalable.
- The store owns the data.
- The composable owns the behavior.
- The component owns the UI.
Once this separation clicks, Vue suddenly becomes effortless to scale.
The moment things start to feel “clean” again
A junior developer becomes mid-level the moment they realize this:
- not all state belongs in a component
- not all state belongs in Pinia
- not all logic belongs in a composable
A clean Vue architecture emerges when every layer has a single responsibility:
- Components show data
- Composables coordinate behavior
- Stores own global domain state
- API modules own network calls
- Types describe structure
- Reactivity stays predictable
Clean code is not magic.
It’s the result of putting things where they belong.
A short message for juniors reading this
If your Vue code feels messy right now — that’s normal.
It doesn’t mean you're bad at writing components.
It means you're ready to start learning architecture.
Your goal isn’t to memorize APIs.
Your goal is to understand how data should move through an app.
Once you learn that, scaling will feel natural.
Top comments (0)