Keeping Vue Components Maintainable and Organized
In any large frontend project, keeping your Vue components simple and understandable is crucial for long-term success. Complexity in the frontend doesn’t scale linearly; it grows exponentially. That’s why it’s vital to establish principles for simplicity before adding new features, not after.
So how do you keep things simple?
- Include no more functionality than needed
- Use a linear line of logic
- Write code like a good article, from high level to low level.
- Use only abstraction layers you need right now (not the layers you think you’ll need later).
- Use syntactic sugar (f.e to reduce nesting). For Vue use
<script lang="ts" setup>, almost always.
What are the tell tale signs when your vue component becomes to complex?
- When it contains lots of dependencies.
- When it contains a lot of logic for edge cases.
- If it contains too much logic on the same page (wrong detail level)
- Too much nesting
Now let’s explore some patterns which make your Vue components to complex to maintain and how to fix them
Multiple ‘pages’ on one page
There are many forms of this, but two of the most common patterns are:
Artificial Pages:
<template>
<div class="success" v-if="page == 'start' ">
I accept the terms:
<input type="button" @click="page = 'sucess'" />
</div>
<div class="success" v-if="page == 'success' ">
Action succeeded.
</div>
</template>
The common modal pattern:
<a @click = "itemId = '11' ">Show Item 1</a>
<a @click = "itemId = '22' ">Show Item 2</a>
<modal v-if="itemId != null">
.. display more data which you need to get from an array or object
<a @click='itemId == null' >Close</a>
</modal>
This will get out of hand very quick, because the amount of states you will need to check for multiply. In addition, you will end up with boilerplate logic showing and hiding the dialog. This type of logic is not required, since there is already a better way.
Use Vue router ‘parameters’ instead to reduce this boilerplate logic en seperate the pages.
Below you will find an example of this.
import UserOverview from './UserOverviewPage.vue'
import User from './UserPage.vue'
// these are passed to `createRouter`
const routes = [
// dynamic segments start with a colon
{ path: '/users/', component: UserOverview },
{ path: '/users/:id', component: User },
]
You can now refer to the current user like this:
<template>
<div>
<!-- The current route is accessible as $route in the template -->
User {{ $route.params.id }}
</div>
</template>
It helps to keep things nice and clean. Pages really become pages instead of virtual stuff which look like pages. In addition you will get unique deep links and navigation, even for modals.
Stop making everything Abstract
No, you don’t need a Vue components for <p>, <h1> and <h2>. I understand that it makes everything consistent across views and pages, but for any slight change, you will need to update the abstraction as wel. This sounds like lots of wasted time to me.
My tip is just to keep it practical. Use one class (f.e .heading-small) and document it.
In frontend it not too bad to repeat some code and patterns. Java developers or php developers often make this mistake when moving to frontend. They will write a render method which generates forms or input fields because they don’t want to repeat code. In frontend, classes and html is meant to be repeated across pages!
Only use components when it realy makes sense and when it makes your life easier.
Too many inline classes
The opposite of making everything to abstract, is not using abstractions at all. This will make it difficult to keep components consistent. Even when you use a lot of utility classes, like in tailwind, you should make some abstract classes also. They are easy to remember and easy to change. A CSS class has also less maintenance overhead tan encapsulating it in a Vue component.
So to summarize don’t use a Vue component like <HeadingSmall /> but do use <h2 class="heading-small"> and
.heading-small{
@apply font-small text-red etc
}
API data Stores
While an API date store sounds great in theory, I would recommend against it. Unless you have an explicit reason.
The main reasoning of using a store is to reuse data and reduce load on the server. It should keep things nice and fast. This is true, but it is a feature rarely necessary.
A server should be able to handle a few rest calls for most applications. In addition when you use an API store, you are also responsible for keeping it up to date (for example when to refresh, delete or modify the data).
So what started as a simple API call introduced an ‘interface’ and dealing with ‘cached’ data.
Like all tips in this article, only use it when it does make sense. Don’t solve problems you dont’t have yet.
Start like this:
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
const data = ref<any>(null);
onMounted(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then(res => res.json())
.then(json => {
data.value = json;
});
});
</script>
Then move to shared utility methods.
export async function useExampleApi(){
return fetch('https://jsonplaceholder.typicode.com/users')
}
And only after that use a full store with getters and mutators.
// apiStore.ts
import { ref, computed } from 'vue';
const state = ref<any[]>([]); // main data store
// Getter
export const getData = computed(() => state.value);
// Mutators
export function setData(newData: any[]) {
state.value = newData;
}
export function addItem(item: any) {
state.value.push(item);
}
// API fetcher
export async function fetchApi(url: string) {
const data = await fetch(url).then(res => res.json());
setData(data); // assign using mutator
}
Smart use of typescript types
You don’t need types for everything, but they are handy.
Personally I use type interference as much as possible. In addition, local, well named variables don’t need types.
The problem with defining type interfaces for everything is that your code becomes overly verbose and noisy.
For communicating with an API, I only define types for complex calls and data structure. Especially when an API changes a lot, I don’t always write types. You need runtime validation anyway. If an API publishes types, then it is great, import and use them, if not don’ try to make them up if it does not add clearity to your code.
In addition, you can just keep the type you do use in the Vue component file. No need to export them when they are not used anywhere else (yet). I hate early abstraction and I think it is the main problem projects take to long to deliver. Always keep in mind you are not solving problems you don’t have yet!!
Complex error states
It sound tempting to validate everything client sides as well as server sided.
However, these gains are insignificant when compared to the downsides
- Bugs in which client side validating fails, but server side is actually valid
- Client side validation which allows stuff the server doesn’t (requires you to interpret the error messages any ways)
Always return a structure JSON object with errors per field in your backend.
Here is an example of such an object.
{
"status": "Bad Request",
"message": "Validation failed",
"errors":{
"email": ["must be unique", "must be work email"]
}
}
Error & Success Feedback
Toast notifications are a godsend to report back if something worked or not. Especially if you don’t use an API Store to cache and store post / put and delete requests. It allows displaying an message without having to think about where on the page it should appear. It works equally nice on mobile and desktop. You don’t have to think about navigation and where to display it. You just fire a toast notification
Conclusion
I hope you liked this article. It is not always easy to keep things simple and a lot of people mistake complex software for good software.
Feel free to share this with others who need to learn this or try to force you to make stuff complicated.