Anti-Clever - Pt. 3 Vuex Store as a Concept, not a Utility
Anti-Clever TOC
- Part 1: Introduction
- Part 2: Flattening Component Structure
- Part 3: Vuex Store as a Concept, not a Utility (active)
Docs: Vuex Documentation
State Management is one of the most elusive topics for most developers to wrap their head around, and, quite frankly, one of concepts that when done wrong leads to messy, complicated, and hard to understand code.
There's two main parts to this topic - the first, which is a more basic issue, is around the concept of props drilling and the misuse of passing data around one's app. I'd argue this should be a fundamental question for any person hiring a Vue developer; whether that developer knows the flaws of the props/emit cycle, and what state management actually means, which was covered in Part 2.
The second issue, which is a more nuanced, has to deal with management the conceptual organization of state, which also connects to app architectural organization. While I had a lot of these ideas on my own, I highly recommend Thomas Findlay's Vue - Road To Enterprise, which really elevates a lot of these principals around conceptual versus utility driven architecture.
Vuex As A Utility
This structure of store organization aligns with the structure of the app's pages.
Let's take the example of a messaging app, which has a page structure like this:
+ components
- Sidebar.vue
- Footer.vue
- Card.vue
+ pages
+ dashboard
+ inbox
- index.vue
+ outbox
- index.vue
- index.vue
+ about
- index.vue
+ index.vue
This app features four pages: dashboard
, inbox
, outbox
, and about
. Additionally, there are three components, two for layout: sidebar
and footer
, and a UI component for a card
.
The typical Vuex implementation for this type of app, pretty closely aligns with this page structure:
+ store
+ dashboard
- index.js
- inbox.js
- outbox.js
Pretty straight forward, right?
Here's where the Anti-Clever model comes into play.
Let's take a deeper look at the inbox and outbox store files.
More than likely these two files are calling the same API endpoint, let's just call it messages
. The messages
endpoint receives a query based on the desired type of message, either inbox
or outbox
.
Sometimes, when a frontend is overburdened due to a poor API response model, the messages
endpoint may return a dump of messages where the frontend is forced to handle filtering the returned data. I would mostly recommend pushing back on backend developers to add a robust filtering system at the point of delivery, leaving the frontend to be as dumb as possible.
But in certain cases where that can't be done, or there's a desire to save on API calls for financial, or non-technical, reasons, the Anti-Clever mentality also holds true, and if anything makes this approach a bit clearer.
Within the inbox
and outbox
store files, there are two separate calls to the messages
API, passing the proper payload to return inbox or outbox messages. This causes there to be two places in which the messages
API call needs to be maintained.
For two calls, this may not be a big deal, but imagine there's 100 places where this needs to be maintained, and your backend developer just decided to add a required parameter that needs to be refactored in 100 places.
Yikes. Ctrl+Shift+F for a couple of days.
Abstracting as a Band-Aid to A Broken Foundation
Just like a house built on a poor foundation, organizing Vuex as a Utility leads to further bad decisions made to solve the problems that arise.
Typically, to solve the duplicitous issue with inbox
and outbox
the logic of the messages
API call will be abstracted out, and a helper asset (or even worse a mixin) will be created to handle the call and response of the API.
+ assets
+ js
- apis.js
+ store
+ dashboard
- index.js
- inbox.js
- outbox.js
And within that apis.js
file is a function that would look something like:
const fetchMessages = (type) => {
return axios.get('messages')
.then(({ data }) => {
return data?.messages || ''
})
}
This appears to be cleaner calls within inbox
and outbox
where rather than two separate calls need to be maintained, there's one.
We're all good, right?
This is the solution that most guiltily violates Anti-Clever principals. This leads down a very long road of pain based on the next question: what if inbox needs to handle the message data differently than outbox?
The ramifications hurt so bad.
The reason why this can go off the rails so quickly is the distance that lives between what inbox
is asking, and what messages
is doing. The utility between the two concepts is vast, because in reality inbox
is wanting to do something totally different than what messages
is providing.
This is why our simple apis.js
helper asset very quickly turns into:
const fetchMessages = (type, model) => {
return axios.get('messages', {
data: {
type
}
})
.then(({ data }) => {
let messages
if (type === `inbox`) {
messages = data.messages.filter(id => startsWith(id, 'inbox-new'))
}
if (type === `outbox`) {
messages = data.messages.filter(id => !includes(id, '-'))
}
if (model === 'warning') {
messages = warningStyle(messages)
}
if (model === 'greeting') {
messages = greetingStyle(messages)
}
return messages
})
}
And then in each of the store files, it becomes something like (example only for the inbox
store file):
export const state = {
messages: []
}
export const actions = {
fetchInboxMessages({ commit }) {
inboxMessage = fetchMessages('inbox', 'greeting')
commit('SET_INBOX_MESSAGES', inboxMessage)
}
}
export const mutations = {
SET_INBOX_MESSAGES(state, message) {
state.messages = messages
}
}
And the spaghetti has begun.
If you're trying to debug a component and there's an issue arising from state.inbox.messages
, you have to take three steps to even begin to understand what's happening:
inbox.js -> apis.js/fetchMessages -> warningStyle
With all three in totally different files, and areas of your app.
Your mind gets stretched thin with this type of problem. The paths of interpretation mount and mount, and before you know it, a bug fix that should take 5 minutes, now takes 2 days.
The main problem is that the information is too far away from where it's being handled. The human mind is not well suited to understand information like this.
Vuex Store as a Concept
This entire runaround could have been avoided if the the organization of the store was addressed directly, rather than covering over the issue with building out more and more code.
The question boils down to: what is actually trying to be accomplished here?
The false assumption in the previous architecture is that the knowledge covered by inbox
and outbox
is the same thing that message
is trying to cover.
But, in reality, inbox
and outbox
are subsections of message
, not the other way around.
And without leaving too much work to undoing a conceptual puzzle of how to deal with this problem, it's easily diagnosed and solved by aligning your store files with your API calls.
That means, our Vuex store architecture should look something like:
+ store
+ messages
- index.js
+ pages
+ dashboard
Note: I left dashboard there because there are definite use cases to have a Store file attached to a page, but should be separate from conceptual store files.
Then, the file would look something like this:
export const state = {
messages: []
}
export const getters = {
getStyledMessages: (state) => (messageStyle) => {
switch (messageStyle) {
case 'warningStyle':
return warningStyle(state.messages)
case 'greetingStyle':
default:
return greetingStyle(state.messages)
}
}
}
export const actions = {
fetchMessages({ commit }, type) => {
return this.$axios.get('messages', { data: { type }})
.then(({ data }) => {
commit('SET_MESSAGES', data?.messages || [])
})
}
}
export const mutations = {
SET_MESSAGES(state, messages) {
state.messages = messages
}
}
And, in case you're dealing with an endpoint that dumps all the messages without any filtering:
export const state = {
messages: []
}
export const getters = {
getMessagesByType: (state) => (messageType) => {
return state.messages.filter(message => message.type === messageType)
},
getStyledMessages: (state, getters) => (messageType, messageStyle) => {
const messages = getters.getMessagesByType(messageType)
switch (messageStyle) {
case 'warningStyle':
return warningStyle(messages)
case 'greetingStyle': // for informative purposes
default:
return greetingStyle(messages)
}
}
}
export const actions = {
fetchMessages({ commit }, type) => {
return this.$axios.get('messages')
.then(({ data }) => {
commit('SET_MESSAGES', data?.messages || [])
})
}
}
export const mutations = {
SET_MESSAGES(state, messages) {
state.messages = messages
}
}
This has now changed the dynamic where the majority of the information about messages
live in the same mental space. Understanding the source of messages
, how they're being returned, how they're being filtered, and how what is styling them (with only the stylized functions being abstracted), are all living under the same room.
This very directly handles the response from the messages
API, placing it as a state item, and then attempting to mutate it based on what's needed via getters, or in some cases a function within your component.
Fundamentally, the data should be placed into state undisturbed; this makes it understandable without having to chase down intermediate changes to understand why the output is what it is.
In the same vein, the output is much closer to the mutations that are pertinent to it. Again, allowing debugging to be much closer to the issue, rather than chasing down middle interpretations.
This makes it much easier for any dev to understand what's happening to the messages
concept, especially for a dev that's looking at the code for the first time, and makes refactoring much simpler because of the lack of interpretation along the way.
This also follows very closely to Test Driven Development mentality (a step I'm skipping in this series, but is fundamental to mentally clarifying code). Each step of the process is clearly differentiated, and most of the interpretation of data is done at the point of response, and not in some middle point that requires following a thread of code to discover where the issue lies.