Practical Guide to MVC for Front-End Development
The MVC pattern is not dead, despite assertions from some influencers. It’s a proven architecture that prevents tightly coupled systems, benefiting both back-end and front-end applications.
It’s not uncommon to encounter claims like “MVC is dead” or “MVC is outdated.” These sentiments often arise because certain frameworks overlook established software engineering principles in favor of new approaches.
However, even modern frameworks can’t escape MVC entirely.
React Components act as controllers that define views using a store system (model) to manage application state and actions. Not so different from well stablished MVC systems.
No?
So take a look at this Laravel example:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Book;
use Carbon\Carbon;
class BookController extends Controller
{
public function index()
{
// Fetch all books from the database
$books = Book::all();
// Additional logic: filter books published in the last year
$recentBooks = Book::where('published_date', '>=', Carbon::now()->subYear())->get();
return view('books.index', compact('books', 'recentBooks'));
}
}
MVC ok? Let's see a different React example?
import React from 'react';
import useBookStore from '../store/bookStore';
import BookListView from './BookListView.jsx';
const BookList = () => {
const { books, filter, setFilter } = useBookStore((state) => ({
books: state.books.sort((a, b) => b.date - a.date), // Sort books by most recent
filter: state.filter,
setFilter: state.setFilter,
}));
const handleFilterChange = (event) => {
setFilter(event.target.value);
};
return BookListView({ filter, handleFilterChange, books })
};
export default BookList;
The implementation details are not important. Here, the component acts as the controller, handling user events and interacting with a model (store) to update the view. The foundational concepts of MVC persist, regardless of attempts by frameworks to abstract them away.
Certain concepts endure because they’ve navigated challenges over the years — MVC is a testament to that resilience, akin to the enduring value of a good book.
Let’s delve into a ubiquitous scenario that all developers and consumers encounter: the Shopping Cart Application.
Here’s what we expect from such an application:
- Users can add products to the cart.
- The cart maintains a distinct list of added products, along with their respective quantities and prices.
- Users can update the quantity of specific products (not allowing zero quantities).
- Users can remove products from the cart.
- The cart displays the total number of items and the overall product cost.
- If the total exceeds $50, a message should indicate eligibility for free shipping.
How can we utilize MVC abstraction to decouple this application?
The Implementation
Instead of using hyped frameworks like React or Angular, I’ll simplify using Jails. This approach underscores how the design of our solutions outweighs any specific framework dependency.
Controller
The component serves as an abstraction that mounts into a custom element, managing interactions between the model, user inputs, and view updates.
Model
Here, we employ a store implementation (like Onijs) that acts as the model in our MVC abstraction. This store houses data, manages state changes, and enforces business rules independently, ensuring testability.
View
The view layer, kept simple in Jails, uses HTML with custom elements like <app-mart /> to render components and directives for data interpolation. This approach supports server-side rendering (SSR) and static-site generation (SSG), promoting performance and user experience.
The Controller
A Jails component is an abstraction designed to mount into a custom element, enabling it to modify, listen to, and update its child elements. It registers events, retrieves IDs, and other relevant information from the view, and dispatches actions to a store with necessary data (such as IDs). This allows the store to identify the specific item in the product list that needs manipulation.
The view layer responds to changes in the component’s local state, which is updated based on store updates. The main function serves as the component’s entry point, handling event registration and setting respective callbacks.
Within the store, patternMatch is a method used to specify actions that the component listens for. For instance, PRODUCTS_LOADED is distinguished from other actions because it pertains to the initial rendering of the products list. This action is internally triggered by FETCH_PRODUCTS once it completes.
Other actions follow a similar pattern, where they retrieve data from the store and update the component’s local state accordingly (refer to the update function for details).
import type { Component } from 'jails-js'
export default function appMart({ main, on, state, dependencies }: Component) {
//
const { minicart } = dependencies
main(() => {
// Binding all events
on('click', '[data-open]', toggleCart(true))
on('click', '[data-close]', toggleCart(false))
on('click', '[data-add]', onAddToCart)
on('click', '[data-increment]', onIncrement)
on('click', '[data-decrement]', onDecrement)
on('click', '[data-remove]', onRemove)
minicart.patternMatch({ PRODUCTS_LOADED: loadProducts })
minicart.subscribe(update)
minicart.dispatch('FETCH_PRODUCTS')
})
const loadProducts = ({ products }, payload) => {
state.set({ products })
}
const toggleCart = (toggle) => (e) => {
minicart.dispatch('TOGGLE_CART', toggle)
}
const onAddToCart = (e) => {
const id = Number(e.target.dataset.add)
minicart.dispatch('ADD_TO_CART', { id })
}
const onIncrement = (e) => {
const id = Number(e.target.dataset.increment)
minicart.dispatch('INCREMENT_PRODUCT', { id })
}
const onDecrement = (e) => {
const id = Number(e.target.dataset.decrement)
minicart.dispatch('DECREMENT_PRODUCT', { id })
}
const onRemove = (e) => {
const id = Number(e.target.dataset.remove)
minicart.dispatch('REMOVE_PRODUCT', { id })
}
const update = ({ cart }) => {
state.set({ cart })
if (cart.freeshipping) {
alert('You have qualified for free shipping!')
}
}
}
export const model = {
cart: {},
products: [],
}
Noticeably, the Controller avoids handling extensive business logic. This approach is beneficial as it allows me to concentrate on orchestrating the system, while delegating specific concerns to appropriate layers.
The Model
In this example, we utilize a Store implementation acting as the Model in our MVC abstraction. I’ve developed my own solution, Onijs, because I preferred a Store based on event-driven architecture rather than traditional object-reactive systems. Additionally, I aimed for a straightforward “State Machine” approach, which I couldn’t find in existing Store implementations.
In Onijs, we primarily define two parameters: initialState and Actions. The Controller dispatches actions to the store using the following syntax:
store.dispatch('MY_ACTION', { ...options })
When calling an action and passing parameters to it, Onijs invokes a function that accesses the current state and determines what needs modification.
For further insights into how the Oni store operates, you can explore it here.
It’s crucial to understand that the Store encapsulates business rules, focusing solely on data management. This design allows for seamless reuse across different frameworks and facilitates independent testing.
// "Global" variable to control when to display free shipping alert
let freeShippingAlertCount = 0
export const initialState = {
products: null,
cart: {
items: {},
total: 0,
quantity: 0,
opened: false,
freeshipping: false
}
}
export const actions = {
FETCH_PRODUCTS: async (state, payload, { dispatch }) => {
const response = await fetch('https://dummyjson.com/products')
const data = await response.json()
dispatch('PRODUCTS_LOADED', { data })
},
PRODUCTS_LOADED: (state, { data }) => {
return {
products: data.products,
loading: false,
}
},
ADD_TO_CART: (state, { id }) => {
const product = state.products.find((p) => p.id == id)
id in state.cart.items
? (state.cart.items[id].quantity += 1)
: (state.cart.items[id] = { id, product, quantity: 1 })
return {
cart: {
...state.cart,
...commonUpdates(state.cart.items),
},
}
},
INCREMENT_PRODUCT: (state, { id }) => {
const item = state.cart.items[id]
item.quantity++
return {
cart: {
...state.cart,
...commonUpdates(state.cart.items)
}
}
},
DECREMENT_PRODUCT: (state, { id }) => {
const item = state.cart.items[id]
if (item.quantity > 1) {
item.quantity--
}
return {
cart: {
...state.cart,
...commonUpdates(state.cart.items)
}
}
},
REMOVE_PRODUCT: (state, { id }) => {
delete state.cart.items[id]
return {
cart: {
...state.cart,
...commonUpdates(state.cart.items),
opened: state.cart.quantity > 0
}
}
},
TOGGLE_CART: (state, toggle) => {
return {
cart: {
...state.cart,
...commonUpdates(state.cart.items),
opened: toggle
}
}
}
}
const commonUpdates = (items) => {
//
const list = Object.values(items)
const total = list.reduce(reduceTotal, 0)
const quantity = list.length
const freeshipping = checkFreeShipping(total)
return {
quantity,
total,
freeshipping
}
}
const reduceTotal = (acc, item) => {
acc += item.product.price * item.quantity
return acc
}
const checkFreeShipping = (total) => {
if (freeShippingAlertCount > 0) return false
if (total > 50) {
freeShippingAlertCount++
return true
}
return false
}
In addition to the actions, there are some helper functions at the end of the file. These functions are designed to prevent logic repetition and streamline operations.
The View
In my opinion, the view layer should avoid unnecessary complexity. That’s why I opted to keep it as simple as possible with Jails. The following code consists of regular HTML, featuring the <app-mart /> custom element where our component is mounted, along with HTML directives for data interpolation.
One advantage of the Jails approach is its compatibility with server-side rendering (SSR) in systems like WordPress, .NET, Java, or any Node.js static site generators (SSG's) using templates such as Pug or Nunjucks.
This approach helps reduce JavaScript code in bundles by keeping HTML separate, leading to faster page loads and improved user experience.
<app-mart>
<template>
<div
class="fixed top-0 left-0 w-full flex items-center bg-gray-300 p-4"
>
<span class="text-lg font-bold">JailsMart</span>
<div class="relative ml-auto flex items-center">
<span class="px-2">🛒</span>
<button
data-open
class="flex text-white items-center rounded-full bg-sky-600 px-4 py-2 text-white hover:bg-sky-700"
>
<span>Cart (${cart.quantity})</span>
</button>
<div
html-if="cart.opened"
class="absolute right-0 top-8 z-10 mt-2 w-80 rounded-lg bg-white shadow-xl"
>
<div class="relative p-4">
<h2 class="mb-4 text-lg font-semibold">
Your Cart
</h2>
<button
class="absolute right-4 top-4 rounded-full p-1 hover:bg-gray-100"
aria-label="close cart"
data-close
>
×
</button>
<div
html-for="cartProduct in cart.items"
class="flex items-center justify-between border-b border-gray-200 py-2"
>
<div class="flex items-center">
<img
html-src="cartProduct.product.thumbnail"
alt="Product"
class="mr-4 size-12 rounded object-cover"
/>
<div>
<p
class="font-medium"
html-inner="cartProduct.product.title"
>
${cartProduct.product.title}
</p>
<p class="text-sm">
$${cartProduct.product.price}
each
</p>
</div>
</div>
<div class="flex items-center">
<button
class="rounded p-1 hover:bg-gray-200"
html-data-decrement="cartProduct.id"
>
-
</button>
<span
class="mx-2"
html-inner="cartProduct.quantity"
></span>
<button
class="rounded p-1 hover:bg-gray-200"
aria-label="Add 1 to quantity"
html-data-increment="cartProduct.id"
>
+
</button>
<button
class="ml-4 rounded p-1 text-red-500 hover:bg-red-100"
html-data-remove="cartProduct.id"
>
🗑
</button>
</div>
</div>
<div class="mt-4 border-gray-200 pt-4">
<p class="text-lg font-semibold">
Total: $${cart.total.toFixed(2)}
</p>
</div>
</div>
</div>
</div>
</div>
<div
class="mt-16 grid grid-cols-1 gap-6 bg-gray-100 p-8 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
>
<div
class="overflow-hidden rounded-xl bg-white shadow-lg"
html-for="product in products"
>
<img
html-src="product.thumbnail"
html-alt="product.title"
class="h-48 w-full object-cover"
/>
<div class="p-4">
<p
class="mb-2 overflow-hidden truncate text-lg font-medium text-gray-800"
html-inner="product.title"
></p>
<div class="flex items-center justify-between">
<p class="text-xl font-bold">
$${product.price}
</p>
<button
class="rounded-full bg-sky-600 px-4 py-2 text-white transition-colors duration-300 hover:bg-sky-700"
html-data-add="product.id"
>
Add to cart
</button>
</div>
</div>
</div>
</div>
</template>
</app-mart>
Now, the application running on StackBlitz:
https://medium.com/media/4111434621c9cf190331180d857fd694/hrefConclusion
Consider adopting MVC design at the outset of your project. It’s particularly beneficial when the future evolution of your system is uncertain, allowing you to introduce additional abstractions as needed.
One notable aspect of this example is that I didn’t decompose every single product into its own component. This decision was made by the fact that the product cards in this use case don’t require such granularity of abstraction.
Understanding when and where to introduce abstractions is crucial. Sometimes, it’s more efficient to proceed straightforwardly, learning from the process and structuring your application in a way that balances complexity with the value gained from added layers of abstraction.
That's all I got for you guys.
Thanks for reading.