Implementing Model-View-Presenter Pattern With Vue
Background
For the past few weeks, my major task has been refactoring the old step-wise drafting process of our app into a much more simplified one. Instead of dividing the drafting and submission process into several steps like Drafting, Tagging, Feedback and Finalize, the goal is to have one unified interface where the features previously scattered across different steps can be integrated in one UI by a plug-in manner, similar to how VS Code plugins add new functionalities to the Activity Bar and Side Bar.
The benefits are obvious: No duplicate event handlers implemented for shared components used across different pages; No need to care about updating data to the backend before navigating to other steps; The app feels more like an SPA and less like web pages; The new UI feels much more intuitive to new users, etc.
The Migration
Dynamic Component
Based on the requirements, it is pretty clear that we want to load the feature components in a dynamic fashion into the left and right panel. Vue provides support for this OOB via dynamic components.
Using dynamic components is easy. You only need to bind the component definition or name to Vue’s <component>
using the is
attribute:
|
|
Cool! Let’s try to dynamically bind the ExampleView
to a <component>
in right panel. Wait a minute… What about the props and event bindings?
|
|
Lucky for us, all the Vue directives work in the dynamic <component>
as well. So in a similar fashion, you can also pass props and bind event handlers to a dynamic component like so:
|
|
Here currentRightComponentProps
and currentRightComponentEvents
are both local data properties. For a feature component ExampleView
, these two values could look something like these:
|
|
|
|
And with a click event handler on the corresponding item within the right sidenav that will trigger the dynamic loading, we have something like this (semi-pseudo code):
|
|
Nice! We have a functional dynamic component setup and ExampleView
works as expected! ✨ However, as you might have noticed we have introduced a big problem here. 🔍
That’s right! All these ExampleView
’s implementation details (data, computed properties, event handlers, etc.) are actually living inside the Drafting
component. Not only does it violate the single responsibility principle, but it will also hurt maintainability and extensibility down the road. As more “plugin” features are added to this interface, we can expect a 1000-line Vue component in the not-too-distant future. 😱 We definitely need a better structure to organize this, and that brings us to the meat of this blog post the Model-View-Presenter pattern.
MVP (Model-View-Presenter) Pattern
What is MVP pattern? In short, it’s a pattern derived from MVC and serve as a structure to better maintain separation of concern. There are a few layers:
Model
- Where the data sources livesView
- Where the UI rendering happensPresenter
- Where the complex domain/UI logic lives
In the case of Vue (or any frontend framework), View takes care of rendering HTML elements and event handling, presenter takes care of data transformation according to domain or UI logic, and model takes care of providing and persisting data via various I/O options. Therefore, we can roughly map it to the following concepts in Vue:
Model
➡️ Smart (Container) Components w/ Vuex + AxiosView
➡️ Dumb (Presentational) ComponentsPresenter
➡️ Composables
There are some key points that worth emphasizing:
- Only container components have access to Vuex store and various services to make API calls.
- Presentational components have no idea of what other parts of the app are doing. They receive data by
props
and emit events. The only logic they have access to are presenters. - Presenters in this context are no just some random Vue composables. There should be no direct access to the store via something like
useStore
. They should not be making API calls either. In other words, they should be “dumb” as well.
If the guidelines above are violated, you are likely to end up with some components that violate Single Responsibility Principle (SRP) and hence, are less testable and maintainable.
Applying MVP to Drafting.vue
It might be easier to understand the benefits by seeing MVP in action. Following the above guideline, we can now refactor our Drafting
component as such:
1<template>
2 <div class="draft-container">
3 <div class="left-panel">
4 <!-- Left Panel -->
5 </div>
6 <div class="editor-view">
7 <!-- Editor View -->
8 </div>
9 <div class="right-panel">
10 <drafting-side-bar class="sidebar">
11 <img
12 class="icon"
13 src="/static/images/icons/examples.png"
14 alt=""
15 @click="loadRightComponent('ExampleView')"
16 />
17 </drafting-side-bar>
18 <keep-alive>
19 <!-- No more props and event binding on the dynamic component-->
20 <component
21 v-if="currentRightComponent"
22 :is="currentRightComponent"
23 ></component>
24 </keep-alive>
25 </div>
26 </div>
27</template>
28
29<script>
30export default {
31 data(){
32 return {
33 currentRightComponent: null,
34 }
35 }
36 // No more example-related data, computed properties and methods
37 methods: {
38 loadRightComponent(componentName) {
39 if (componentName === this.currentRightComponent?.name) {
40 this.currentRightComponent = null;
41 } else {
42 switch (componentName) {
43 case "ExampleView":
44 this.currentRightComponent = ExampleView;
45 break;
46 default:
47 break;
48 }
49 }
50 },
51 },
52};
53</script>
By taking a look at the larger picture, we can visualize the overall architecture in this diagram:
Essentially, we are turning the top-level Drafting
component into a container component for three container components. It hosts or dynamically loads other container components (e.g. ExampleView
) but does not concern itself with their implementation details.
XXXView
pattern here. Yes, that’s the naming convention chosen to indicate container components. I picked it for our project but you can choose your own.The container components, like ExampleView
, can connect to Vuex store, make API calls, provide data and event handlers for the child presentational components. In simple cases, the presentational components render to HTML and that’s it. However, sometimes there can be complex business or UI logic and again, you should want extract it and separate it from the Vue framework whenever possible. There’s more than one way to skin a cat but the simplest solution nowadays would be a Vue composable via Composition API. You can also extract it into a library/module that you can extend and improve in the future.
An example of such presenter is the QuillEditor
class from an internal editor
library that empowers the QuillEditor
Vue component, which in turn empowers the EmailEditor
component. Inside this QuillEditor
component, there is an line for editor initialization:
|
|
where the definition of class QuillEditor
looks something like this:
|
|
As you can see, we extended the default rich text editor with tagging and colored highlighting feature, and such domain logic and UI behavior are nicely encapsulated inside this QuillEditor
class.
So what are the benefits? There are, IMHO, quite a few:
- Better code organization and separation of concern. You know the role of each component so when you need to add a new feature, you know exactly what type of logic should go where. The same also applies to debugging.
- Looser coupling. Take the mentioned
QuillEditor
library for example: It’s loosely coupled with the main app. If we want to add new functionalities or even swap the underlying implementation (e.g. Quill ➡️ TipTap) in the future, we can simple refactor thispresenter
without worrying breaking other parts of the app as long as it implements those interfaces correctly. - Better testability. E2E/Integration testing can be brittle and hard to set up (I am not saying you should never perform them but it can be tricky!). By having a clear separation between container and presentational components, it would be much easier to test a subset of “dumb” components. Even better, by extracting the critical UI and business logic into composables and libraries, you can test the critical part more efficiently without involving a large UI framework that’s Vue.
- Better maintainability. It is now easier to reason about your code, easier to test and get critical logic right, easier to locate certain code and easier to refactor old features and add new ones. Maintainability 🆙
Conclusion
There you have it! An example of how to refactor a complex component and apply MVP along the way to make our code better. In case you want to learn more about this pattern, there are a few articles that might interest you (despite being Angular-focused, the ideas introduced should be framework-agnostic):
Hope this post has been helpful and see you in the next one!