So you’re a seasoned React developer, but you’ve never applied the Model-View-ViewModel (MVVM) architectural pattern to your work. No worries! In this post, I’ll give you a basic introduction to what MVVM can look like in React (using MobX) to create highly testable code.
What is MVVM?
I like to use this meme to simply but accurately describe how I leverage the MVVM pattern in my apps.
Each screen or component in an app will have its own set of behaviors for displaying information or performing actions at the user’s request. MVVM is the separation of the code that drives the UI (e.g., setting colors on a button or text on a label) from the business logic that drives it (e.g., creating the string “Hello, John” or “Hello, Guest” depending on if John is authenticated) by moving that logic outside of the component and into a standalone object.
What is MVVM’s purpose?
Mostly testing. MVVM allows us to write unit tests much more easily on that object’s logic than blurring the line into UI tests. We don’t need to render components or depend on any of the UI-specific lifecycle or other dependencies. Instead, we can just write some pure business logic, inject any of our own dependencies, and make sure it appropriately outputs whatever the UI expects.
MVVM keeps view-specific logic coupled to the view in the sense that it’s not managed in a more global setting (like it can be when using Redux), but it also separates/decouples it from the view because it’s a completely separate object that is 100% created by the developer. That means there’s no mystery about how it works and it can be tested at a meaningful 100% code coverage.
Decoupling/separating it from the UI also allows us to create a “dumb” UI, meaning our UI does nothing but display data without manipulating it and passes off interactions to something else (the view model). This makes the UI code simpler (and for me, easier to read) and then also allows it to be more reusable since there’s no logic involved — and makes it super easy to display UI components in Storybook.
What is MobX?
We won’t dive too much into this here, but MobX is a library that provides a way to notify the view that its view model’s state has been updated, which triggers a new rendering (this is called the observer pattern). MobX will be used in the code examples that follow.
The Counter Example
If you’ve read the React Hooks documentation before, you’ve probably seen their counter example. We will construct the counter with an MVVM pattern to go over how it would look different and how to test it. If you’re not familiar with that example, feel free to check out their documentation here to get some context.
We can start by going over the three pieces we will need to do this. The non-MVVM counter component is one function that returns the counter component with its functionality. In order to break this out into an MVVM pattern, we will need one object called the View Model which holds our logic, one object called the View which will take the View Model as a prop and returns the fully rendered view, and one more object to hook them up to each other.
Starting with the View Model, moving all of the logic over to this object and exposing only what the View will need to interact with, we have this:
// ViewModel.ts
import {action, computed, observable} from "mobx";
class ViewModel {
@observable private count = 0;
private document: Document;
constructor(document: Document) {
this.document = document;
this.document.title = `You clicked ${this.count} times`;
}
@action onClick = (): void => {
this.count += 1;
this.document.title = `You clicked ${this.count} times`;
};
@computed get countLabel(): string {
return `You clicked ${this.count} times`;
}
}
export default ViewModel;
In a View Model, all of our outside dependencies are injected (in this case, the `Document`) so that we can mock them out with Jest in our tests. Before we get to tests, though, here is our View:
// View.tsx
import {observer} from "mobx-react";
import ViewModel from "./ViewModel";
interface Props {
viewModel: ViewModel;
}
const View = ({viewModel}: Props) => (
<div>
<p>{viewModel.countLabel}</p>
<button onClick={viewModel.onClick}>Click me</button>
</div>
);
export default observer(View);
Our View is now considered just plain UI code. The View performs no logic itself; everything goes through the View Model, from the information it displays to passing off the user’s interactions.
Finally, we have the “glue” that connects them. This is the piece we’ve referenced in our `Root` or other component (as opposed to `ViewModel.ts` or `View.tsx` directly).
// Example.tsx (to match the React Example function)
const Example = () => {
const viewModel = new ViewModel(document);
return <View viewModel={viewModel} />;
};
export default Example;
This file will look exactly the same (except for the dependencies injected into the View Model) for every MVVM component. Its sole purpose is to inject dependencies into the View Model and return the View so our UI components that use this View do not need to have that boilerplate code.
Tests!
Now onto the main reason for MVVM: tests! The React Hooks documentation has an example for writing tests. Essentially, you need to write a regular component test where the component is rendered before interacting with it.
This is where I get a bit opinionated. Rendering a component and then using query selectors to find pieces in the UI often involves more boilerplate code for setup, teardown, and then actually running the test than being able to write simple unit tests exclusively on the view model I created. Depending on your UI’s setup (in React but also other platforms like iOS or Android), you may also have more difficulty using injection into your UI for any dependencies. With MVVM, I can very clearly define and separate a component’s UI logic from its actual UI and therefore I can test it exclusively. I often skip writing any tests on the actual UI components when with MVVM because my components are so simple and primitive, there’s nothing to test. I would essentially be writing tests on the React framework itself, and I’d rather leave that up to the folks writing the React framework.
Our View Model equivalent of the React Hook test ends up looking like this.
// ViewModel.test.ts
import ViewModel from "./ViewModel";
beforeEach(() => {
// @ts-expect-error We aren't mocking the entire document
mockDocument = {title: ""};
viewModel = new ViewModel(mockDocument);
});
it("has appropriate initial label", () => {
expect(viewModel.countLabel).toEqual("You clicked 0 times");
});
it("has the starting title as the document", () => {
expect(mockDocument.title).toEqual("You clicked 0 times");
});
describe("when clicked", () => {
beforeEach(() => {
viewModel.onClick();
});
it("updates the counter label", () => {
expect(viewModel.countLabel).toEqual("You clicked 1 times");
});
it("updates the document title", () => {
expect(mockDocument.title).toEqual("You clicked 1 times");
});
});
Normally when writing unit tests, we want to test one thing at a time. The React Hooks documentation is testing a few things at once, so I broke those out into their own tests. Other than that, this is an equivalent View Model test to their example component tests. We can test just the logic that drives the counter without going through the UI.
Wrap-up
The use case I’ve shared is a pretty simple one, which may leave you asking “what’s the point?” — which is totally understandable since the counter example is a pretty simple component. How often, though, are our apps as simple as keeping track of a count? It’s much more likely that we have networking calls, asynchronous activity, and a global state to keep track of, which is where we can really start to take advantage of MVVM. With MVVM, we can easily inject these sorts of dependencies and test our UI’s behaviors without actually needing to write UI tests so we can be certain our UI is working as expected.