Thanks for visiting my blog!
In my job as a consultant, I often code review Vue applications. Structuring a Vue app (or Nuxt) can be a challenge as your project grows. It is common to me to see views with a lot of business logic and computed values. This isn’t necessarily a bad thing, but can incur technical debt. Let’s talk about it!
For example, I have a simple address book app that i’m using:
Let’s start with the easy part, I’ve got a component:
<div class="mr-6">
<entry-list @on-loading="onLoading"
@on-error="onError"/>
</div>
In order to react to any state it needs, we’re using emits (e.g. events). So when the wait cursor is needed, we get an event emited to show or hide the cursor. Same for errors. So we have to communicate through props
and emits
. But, I’m getting ahead of myself, let’s look at the component’s properties that it binds to:
const router = useRouter();
const currentId = ref(0);
const entries = reactive(new Array<EntryLookupModel>());
const filter = ref("");
Then it binds the entries (et al.):
<div class="h-[calc(100vh-14rem)] overflow-y-scroll bg-yellow">
<ul class="menu text-lg">
<li v-for="e in entries"
:key="e.id">
<div @click="onSelected(e)"
:class="{
'text-white': currentId === e.id,
'font-bold': currentId === e.id
}">{{ e.displayName }}</div>
</li>
</ul>
</div>
Simple, huh? Just like most Vue projects you’ve seen, especially examples (like I write too). But to serve this data, we need some business logic:
function onSelected(item: EntryLookupModel) {
router.push(`/details/${item.id}`);
currentId.value = item.id;
}
onMounted(async () => {
await loadLookupList();
})
async function loadLookupList() {
if (entries.length === 0) {
try {
emits("onLoading", true);
const result = await http.get<Array<EntryLookupModel>>(
"/api/entries/lookup");
entries.splice(0, entries.length, ...result);
sortEntities();
} catch (e: any) {
emits("onError", e);
} finally {
emits("onLoading", false);
}
}
}
function sortEntities() {
entries.sort((a, b) => {
return a.displayName < b.displayName ? -1 :
(a.displayName > b.displayName ? 1 : 0)
});
}
Not too bad, but it makes this simple view complex. And, if we wanted to test this component, we’d have to do an integration test and fire up something like Playwright to test the actual code generation. This works, but your tests are much more fragile and take a long time to run.
Enter Pinia (or any shared objects). Pinia allows you to create a store that, essentially, creates a shared object that can hold your business logic. By removing the business logic form the components, we can also unit test them. I’m a fan. Let’s see what we would do to change this.
Note, this isn’t really a tutorial on how to use Pinia, but if you want the details look here:
First, let’s create a store:
export const useStore = defineStore("main", {
state: () => {
return {
entries: new Array<EntryLookupModel>(),
filter: "",
errorMessage: "",
isBusy: false
};
},
}
You create a store using defineStore
and expose it as a composable so the first person who retrieves the store, creates the instance. But, importantly, every other calling of useStore will retrieve that same instance. So, in our component we’d just use the useStore to load the main store:
const store = useStore();
And to bind to the store, you’d just use the store. For example, in the component:
<div class="h-[calc(100vh-14rem)] overflow-y-scroll bg-yellow">
<ul class="menu text-lg">
<li v-for="e in store.entries"
:key="e.id">
<div @click="onSelected(e)"
:class="{
'text-white': currentId === e.id,
'font-bold': currentId === e.id
}">{{ e.displayName }}</div>
</li>
</ul>
</div>
All the same data binding happens, it’s just wrapped up in the store. But what about that business logic? Pinia handles that with actions
:
export const useStore = defineStore("main", {
state: () => {
return {
entries: new Array<EntryLookupModel>(),
filter: "",
errorMessage: "",
isBusy: false
};
},
actions: {
async loadLookupList() {
if (this.entries.length === 0) {
try {
this.startRequest();
const result = await http.get<Array<EntryLookupModel>>(
"/api/entries/lookup");
this.entries.splice(0, this.entries.length, ...result);
this.sortEntities();
} catch (e: any) {
this.errorMessage = e;
} finally {
this.isBusy = false;
}
}
},
...
}
If we push the loading of the data into the actions
member, we are adding any functions we need to be exposing to the applicaiton. For example, instead of using a local function, we can just access it from the store:
onMounted(async () => {
await store.loadLookupList();
})
Again, we’re just deferring to the store. You might be asking why? This centralizes the data and logic into a shared object. To show this, notice that the store also has members for errorMessage
and isBusy
. As a reminder, we were using events to tell the App.vue
that the loading or error message has changed. But since we’re just using a reactive
object in the store, we can skip all that plumbing and instead just use the store from the App.vue
:
<script setup lang="ts">
const store = useStore();
// const errorMessage = ref("");
// const isBusy = ref(false);
// function onLoading(value: boolean) { isBusy.value = value}
// function onError(value: string) { errorMessage.value = value}
...
</script>
<template>
...
<div class="mr-6">
<entry-list/>
</div>
</section>
<section class="flex-grow">
<div class="flex gap-2 h-[calc(100vh-5rem)]">
<div class="p-2 flex-grow">
<div class="bg-warning w-full p-2 text-xl"
v-if="store.errorMessage">
{{
errorMessage
}}
</div>
<div class="bg-primary w-full p-2 text-xl"
v-if="store.isBusy">
Loading...
</div>
...
</template>
So, the logic of errors and isBusy (et al.) is contained in this simple store. My component now has only cares about local state that it might need (e.g. currentId is picked and shows the other pane):
<script setup lang="ts">
...
const store = useStore();
const router = useRouter();
const currentId = ref(0);
function onSelected(item: EntryLookupModel) {
router.push(`/details/${item.id}`);
currentId.value = item.id;
}
onMounted(async () => {
await store.loadLookupList();
})
watch(router.currentRoute, () => {
if (router.currentRoute.value.name === "home") {
currentId.value = 0;
}
})
</script>
But what if we need some computed values? Pinia handles this as getters
:
getters: {
entryList: (state) => {
if (state.filter) {
return state.entries
.filter((e) => e.displayName
.toLowerCase()
.includes(state.filter));
} else {
return state.entries;
}
}
}
Each getter is a computed value. So when the state of the store changes, this is computed and can be bound to. You may have noticed a filter property. To handle the change, we’re just binding to an input:
<input class="input join-item caret-neutral text-sm"
placeholder="Search..."
v-model="store.filter" />
Since this is bound to the filter, when a user types into it, our entryList will change. You’ll notice that in the getter, we’re just filtering the list of entries based on the filter. So, if we switch the binding to the entryList, we’ll be binding to the computed value:
<ul class="menu text-lg">
<!-- was "store.entries" -->
<li v-for="e in store.entryList"
:key="e.id">
<div @click="onSelected(e)"
:class="{
'text-white': currentId === e.id,
'font-bold': currentId === e.id
}">{{ e.displayName }}</div>
</li>
</ul>
Except for binding to the filter and to the entryList, the component doesn’t need to know about any of this.
So why are we doing this? So we can unit test the store itself. Make sense?