Vue.js State Management vs. Clean Architecture
Being independent of frameworks is an important attribute of clean architecture. This post explores the challenges of managing the application state in a way that is less specific to Vue.js.
This is how Ucle Bob explains it in The Clean Architecture:
The architecture does not depend on the existence of some library of feature laden software. This allows you to use such frameworks as tools, rather than having to cram your system into their limited constraints.
This means you should not need to be writing the business logic of your application in a framework prescribed way.
Vue.js basics
Vue.js is a JavaScript framework for building user interfaces for the web. At the heart of the framework lies a reactivity system that maps the component state to rendered DOM.
The example snippet below renders <b>Hello Vue!</b>
and if the value
of message
changes the rendered element gets updated as well:
new Vue({
data: {
message: 'Hello Vue!'
},
template: '<b>{{ message }}</b>'
})
Reactive state limitations
Any property under data
becomes a reactive property when the Vue
instance initializes. One might think that this means data
can hold
arbitrary data structures which can be manipulated any way we like.
Unfortunately that is not true, there are limitations:
- Object property addition and deletion is not reactive
- Array item reassignment is not reactive
- ES6 iteration
protocols
are not supported which
means no
v-for
on custom iterables and no support forSet
,Map
and friends.
I have not found a single authoritative source explicitly listing
which types are supported and which are not as data
properties in
Vue.js. It seems like the best approach is to stick to using “basic”
types (Array
, String
, Symbol
, Number
, Object
, Boolean
and
undefined
) only and avoid anything else (no custom prototypes, no
Date
or other language or browser built-in types).
So where is the problem?
The limitations on what types and what methods Vue allows to use might not appear to be a big deal for small and simple use cases. A real life application might eventually reach a point where there appears to be a piece of logic that is independent from the presentation and will benefit from encapsulation - this logic is often called business logic.
The clean architecture approach recommends a business logic implementation to be implemented in a framework independent way but Vue’s constraints mean we can not leverage the full power of modern JavaScript (or whatever language we compile to JavaScript). When implementing data structures or algorithms to be used for representing or manipulating component state, we have to play by the Vue.js rules.
An example
Let’s say we want to implement an application that shows a timeline and allows adding events to it:
<ul id="app">
<li v-for="entry in timeline">
{{ entry.year }}: {{ entry.event }}
</li>
<li>
<input type="number" v-model="year">:
<input type="text" v-model="event">
<button @click="addEvent">Add event</button>
</li>
</ul>
<script>
new Vue({
el: '#app',
data: {
year: 2018,
event: 'Someone clicked a button',
timeline: [
{year: 1946, event: 'ENIAC'},
{year: 1981, event: 'IBM PC'},
{year: 2007, event: 'iPhone'}
]
},
methods: {
addEvent (event) {
this.timeline.push({
year: this.year,
event: this.event
})
}
}
})
</script>
This simple example works as expected as we are mutating the state in a Vue.js compliant way.
Extracting the business logic
As the application evolves we might realize that we often have to add
events to the timeline, we might want to prevent certain operations on
the timeline
array or make sure it is always sorted. This will
result in extracting a Timeline
class, something like this:
function Timeline (initialEntries = []) {
// Keep the cloned entries array private
const entries = initialEntries.slice()
return {
add (year, event) {
entries.push({year, event})
entries.sort((a, b) => a.year - b.year)
}
}
}
// Create a timeline with some entries
new Timeline([
{year: 1946, event: 'ENIAC'},
{year: 1981, event: 'IBM PC'},
{year: 2007, event: 'iPhone'}
])
By wrapping the array and keeping it private we can add our own manipulation methods which can ensure the structure is solid.
However if we substitute the plain array with a Timeline
in Vue
data
it will no longer work. Vue has no idea how to iterate over
a custom class, we have to convert it to an array:
function Timeline (...) {
return {
// ...
toArray () {
// Clone the private array
return entries.slice()
}
}
}
An even cleaner option would be implementing a custom iterable but that is not something Vue can directly use.
Calling toArray()
from v-for
will have the desired results:
<li v-for="entry in timeline.toArray()">
We are less lucky however when it comes to adding entries to the
timeline. Even if we replace push
with timeline.add
this will
not result in updating the rendered DOM:
methods: {
addEvent (event) {
this.timeline.add(this.year, this.event)
}
}
Looks like we reached a dead end and in fact we have been told to only use “basic” types and don’t even try to add our own methods.
The way forward
Turns out the workaround is simple: instead of updating an object
assigned to a data
property in-place, entirely replacing the
instance does kick off Vue’s reactive updates:
methods: {
addEvent (event) {
// Clone the timeline
const timeline = new Timeline(this.timeline.toArray())
// Add an entry to the new timeline
timeline.add(this.year, this.event)
// Replace this.timeline with the new instance
this.timeline = timeline
}
}
Sharp-eyed readers might notice that at this point we could implement
Timeline
as an immutable data structure as there will never be a
need for in-place updates.
This approach has some downsides though:
- It is more code, and more code is more prone to errors and has a higher maintenance cost.
- Iterable data structures have to be converted to plain arrays before passing in to Vue. This has a performance cost.
- A custom class exposing both immutable and mutable methods can lead to confusion as using the latter will not work with Vue.
Conclusion
Your app might be simple enough or 99% of the business logic lives on the server so that the Vue.js constraints discussed here have no big impact on the architecture.
If you are building something more complicated, my recommendation is the following:
- Use Vue.js for building simple and “dumb” components with as little business logic, as possible.
- Use Vuex for managing non-trivial
application state but keep in mind, Vuex
state
has the same limitations as Vue.jsdata
. - Keep everything else outside of Vue.js and Vuex and accept some
complexity tradeoffs. For example:
- Implement data structures and algorithms in their own classes and modules. Consider using immutable data.
- Separate data access (communication with the server or other storage) in it’s own layer (see the Repository Pattern).