Functional patterns don’t fit JavaScript: Embracing Model View Controller on the frontend

Modern frontend development is dominated by React and other functional-like patterns. There’s likely 1000s of frontend libraries that all attempt the same thing: Creating reusable components, embracing a functional pattern around immutable data structures. However, there is a huge problem with these libraries:

Javascript data structures are inherently not immutable.

This causes tons of issues that need careful crafting. Manipulating an object inside a useEffect can cause subtle bugs. There’s tons of potential solutions that “enforce” immutability: Immer, Redux, ImmutableJS - but they all require you to model your data around these tools. In some cases, these tools appear to fix the problems, but you will still end up with hidden gotchas.

I feel like all these functional-like frameworks try to make Javascript something it is not: A functional language with immutable datastructures.

So what is Javascript?

Javascript is a prototype-based, loosely-typed language with robust Object Oriented Programming support

Can we design a frontend development style that embraces Javascript for what it is, instead of trying to make it something it is not? I think we can. We can start looking in patterns that work for Object-Oriented-Programming languages: Model-View-Controller (MVC) and Model–View–ViewModel (MVVM).

Reading MVC, you might immediately jump to your experience with MVC platforms like Ruby on Rails, Django, ASP.NET or Laravel, but those are not the kind of MVC I want to explore today.

I am thinking of a fully client-side MVC experience. One where the views somehow convert data to HTML, controllers that handle events triggered by the views, and models that are rendered by the view and updated by the controller, all within the browser.

Testable, independent models, views and controllers

I think testing is very important. I want to systematically test every line of code written either by me or AI, including my views. Tests must run fast and have no external dependencies. Ideally, I want to test the models, views or controllers independently from each other. Let’s break them down.

The Model

The model is a very important part of the application and requires thorough testing. I want my models to be completely independent from any framework-specific nonsense. Ideally, they are just plain Javascript objects or classes with simple exposed members. They should be easily reconstructible in any state for easy testing.

class Counter {
  constructor(count = 0) {
    this.count = count;
  }

  increment() {
    this.count++;
  }
}

test(".increment increments counter", () => {
  const counter = new Counter(0);
  counter.increment();
  assert.strictEqual(counter.count, 1);
});

As is common in Object-Oriented-Programming, this model couples functions and data, which does not guarantee the model to be easily reconstructible or serializable. It would also expose all functions to any view rendering the model.

A procedural alternative, separating functions and data, could be:

const Counter = {
  create: (count = 0) => {
    return { count };
  },

  increment: (counter) => {
    counter.count++;
  },
};

test(".increment increments counter", () => {
  const counter = Counter.create();
  Counter.increment(counter);
  assert.strictEqual(counter.count, 1);
});

This separation of functions and data allows you to render the view without exposing the class methods to the view. It also allows you to work on serialized and deserialized data (e.g. data received via an API, JSON-deserialized data) without the need to reconstruct the object prototypes.

Either approach has its pros and cons, which is beyond the scope of this blog for now.

The View

I want my (client-side) views to be rendered in any state based on a model that can easily be initialized in any state. It will likely involve some kind of HTML template or existing HTML fragment which can be manipulated in a render() call, rendering the model by updating the DOM. Any user interaction should trigger an event, the view should avoid local state.

These views should be able to exist self-contained without external styles or other components. This should make it possible to preview and test the view with only the model (or a mock copy of the model), completely independent from any other views in any webpage.

One could design the views without relying on the DOM, but this will likely involve some kind of vdom or other means of patching the DOM based on rendered template strings. From a programmer’s perspective, this is nice and easy, but from a optimization perspective - I’m not a fan.

Instead, I think views should embrace the dom, either injecting a template once or relying on an existing HTML document, and manipulating it based on the model’s properties on render.

<template id="counter-view-template">
  <style>
    .root {
      color: blue;
      border: 1px solid blue;
      padding: 3px;
    }

    .count {
      font-weight: bold;
    }
  </style>
  <span class="root">
    Count: <span class="count" data-count></span>
    <button data-increment>+</button>
  </span>
</template>
class CounterView extends HTMLElement {
  constructor() {
    super();
    const template = document.getElementById("counter-view-template");
    const shadowRoot = this.attachShadow({ mode: "open" });
    shadowRoot.appendChild(template.content.cloneNode(true));

    this.countElement = shadowRoot.querySelector("[data-count]");

    shadowRoot.querySelector("[data-increment]")
      .addEventListener("click", this.handleIncrementClickEvent.bind(this));
  }

  render(counter) {
    this.countElement.textContent = String(counter.count ?? 0);
  }

  handleIncrementClickEvent(event) {
    event.preventDefault();
    this.dispatchEvent(new CustomEvent("counter-increment", {
      bubbles: true,
      composed: true
    }));
  }
}

window.customElements.define("counter-view", CounterView);

The view can be used like this:

<counter-view id="my-counter-view"></counter-view>
const counter = new Counter(69);
const myCounterView = document.getElementById("my-counter-view");

myCounterView.render(counter);

myCounterView.addEventListener("counter-increment", () => {
  counter.increment();
  myCounterView.render(counter);
});

Example fiddle: https://jsfiddle.net/cv23r7kd/7/

Testing the view

In my opinion, views need to be visually inspected in any state. You can achieve this by rendering your view in various states on a demo page. This will demonstrate how your view can be used and at the same time it can be used to verify they’re rendered correctly in the browser you’re using.

If you want automated tests, you can either run tests on NodeJS with JSDOM, or you can run tests using a client-side test runner like Mocha, in a real browser. Ideally, you want to design these tests in a way you can run them in the browser and in NodeJS. The NodeJS test suite can then run the first pass to catch any issues with the code, and another more sophisticated tool can run the test suite in a wide array of browsers and report back browser-specific issues.

Your tests could look something like this:

let counter;
let view;

beforeEach(() => {
  counter = new Counter(69);
  view = document.createElement("counter-view");
  view.render(counter);
});

test("render renders the counter's count", () => {
  expect(view.shadowRoot.querySelector("[data-count]").textContent)
    .toEqual("69")
});

test("clicking increment button emits counter-increment event", () => {
  const events = [];
  view.addEventListener("counter-increment", (e) => { events.push(e); });
  view.shadowRoot.querySelector("[data-increment]").click();
  expect(events).toMatchObject([{ type: "counter-increment" }]);
});

You can use Mocha and Jest’s expect to run your tests in the browser. You can use JSDom to run the same tests in NodeJS.

The Controller

The controller depends on the model and view. The implementation is already obvious from the previous example on how the view and model are used together:

class CounterController {
  constructor(counter, counterView) {
    this.counter = counter;

    this.handleCounterIncrement = () => {
      this.counter.increment();
      this.counterView.render(this.counter);
    };

    this.setView(counterView);
  }

  setView(newCounterView) {
    if (this.counterView)
      this.removeView();

    this.counterView = newCounterView;

    this.counterView.render(this.counter);
    this.counterView.addEventListener(
        "counter-increment", this.handleCounterIncrement);
  }

  removeView() {
    if (!this.counterView)
      return;

    this.counterView.removeEventListener(
        "counter-increment", this.handleCounterIncrement);
    this.counterView = null;
  }
}

The usage example can then be changed to:

const counter = new Counter(69);
const counterView = document.getElementById("my-counter-view");
new CounterController(counter, counterView);

You can test it here: https://jsfiddle.net/cv23r7kd/11/

You might look at this controller and think nothing much of it, but for “real” use-cases, the controller can grow fast. It can coordinate the rendering of multiple views of the same model, it can handle async actions of the view or model, and it could dynamically swap out models and views.

Testing the controller

Since the controller also relies on the views, it must be tested in the same environment as the views: The browser and/or JSDom. For simplicity, I choose to use a custom view for testing, since there’s no easy way to assert the rendered result on the CounterView. My TestCounterView extends EventTarget for addEventListener and removeEventListener.

class TestCounterView extends EventTarget {
  state = null;

  render(counter) {
    this.state = { count: counter.count };
  }
}

let counter;
let counterView;
let controller;

beforeEach(() => {
  counter = new Counter(0);
  counterView = new TestCounterView();
  controller = new CounterController(counter, counterView);
});

test("controller renders the view", () => {
  expect(counterView.state).toEqual({ count: 0 });
});

test("controller increments and renders on counter-increment event", () => {
  counterView.dispatchEvent(new CustomEvent("counter-increment"));
  expect(counter.count).toEqual(1);
  expect(counterView.state).toEqual({ count: 1 });
});

test("removeView removes the counter-increment listener", () => {
  controller.removeView();

  counterView.dispatchEvent(new CustomEvent("counter-increment"));
  expect(counter.count).toEqual(0);
  expect(counterView.state).toEqual({ count: 0 });
});

No observing of the model state

A lot of MVC and MVVM patterns currently present on the web (Backbone, Knockout, Angular 1) relied heavily on observables or automatic bindings. This makes it easy for multiple views to automatically update when a model changes, but event-driven programming is hard to debug.

I therefore choose not to make my models observable. I want my models to remain completely agnostic of any knowledge of the frontend.

For applications that rely on a large web of models, views and controllers, the controller layer needs to coordinate the updates between all models and views.

The verbosity of the DOM

The current implementation is pretty verbose and includes more boilerplate than I would like. I expect most of the boilerplate can be DRYed up using small helpers. For example:

<template id="counter-view-template">
  <style>
    .root {
      color: blue;
      border: 1px solid blue;
      padding: 3px;
    }

    .count {
      font-weight: bold;
    }
  </style>
  <span class="root">
    Count: <span class="count" data-ref="count"></span>
    <button data-ref="increment">+</button>
  </span>
</template>
class BoundTemplateHTMLElement extends HTMLElement {
  refs = {};

  constructor(templateId) {
    super();

    const template = document.getElementById(templateId);
    this.attachShadow({ mode: "open" });
    this.shadowRoot.appendChild(template.content.cloneNode(true));

    for (const elem of this.shadowRoot.querySelectorAll("[data-ref]"))
      this.refs[elem.getAttribute("data-ref")] = elem;
  }

  emit(type) {
    this.dispatchEvent(new CustomEvent(type, {
      bubbles: true,
      composed: true
    }));
  }
}

class CounterView extends BoundTemplateHTMLElement {
  constructor() {
    super("counter-view-template");

    this.refs.increment.addEventListener(
      "click", this.handleIncrementClickEvent.bind(this));
  }

  render(counter) {
    this.refs.count.textContent = String(counter.count ?? 0);
  }

  handleIncrementClickEvent(event) {
    event.preventDefault();
    this.emit("counter-increment");
  }
}

window.customElements.define("counter-view", CounterView);

An obvious alternative is LitElement, which fits in nicely with this pattern. Lit does have reactive properties, but you can just use these to read and write the view components (which also makes testing easier).

In a future blog post, I’d love to provide an example of a simple TODO app with server-side communications.