Retrofuturistic MVVM: Achieving View Purity with MobX ViewModels

Dan Imhoff
Published

Just one more useState() won’t make this component any worse.

—Me, a jackass

Component bloat is real. We’ve all experienced it. What was once a simple component with a manageable dozen lines or so is now an endless sea of hooks, callback functions, and inlined sub-components followed by a disgusting mass of JSX. Reading such a monolithic component can become challenging, let alone modifying or refactoring it.

React hooks freed us from class components, but they don’t seem to solve component bloat. In fact, given their ease of use, they often encourage us to add to the view layer indiscriminately, blurring the lines between view logic and business logic.

View Purity

In functional programming, a pure function is a function whose output is the same when given the same input and which has no side effects. Pure components borrow that concept and apply it to the view layer. Components with internal state, e.g. those with useState() hooks, and components which have side effects, e.g. changing document.title in a useEffect() hook, are not pure.

I would like to take it one step further and introduce pure views. Pure views have all the attributes of pure components and have a single responsibility: presentation. Not only are pure views free of internal state and side effects, they are also free of any logic that is not strictly presentational.

If this is starting to sound familiar, it’s because this isn’t a new idea. MVVM (Model-View-ViewModel) is a pattern invented by Microsoft that attempts to separate business logic from view logic by introducing the ViewModel, which acts as a mediator between the model and the view by converting data from the model, deriving and providing state to the view, handling actions and events, and more. Essentially, the ViewModel reduces the view to a stateless template.

Why?

Decoupling non-presentational logic from our views gives us a few benefits:

  • Readability. A clear separation of logic and template means components don’t get polluted. Business logic is self-contained and written in a cleaner, more conventional way.
  • Maintainability. The relationship between ViewModel and component is not 1:1. When components only handle presentation, they become very easy to refactor or split up. ViewModel implementations can be swapped out without changing a single thing in the view layer.
  • Testability. The ViewModel allows us to use unit tests to verify behavior, instead of complicated UI tests. Components become easier to test simply because they’re pure. ViewModels can be easily manipulated in UI tests to evoke desired states and behavior.

Introducing MobX

MobX is a library that makes data structures such as arrays and objects observable. The accompanying library mobx-react-lite makes React components observers. Components rerender when data changes. MobX does not prescribe any particular design patterns, but it happens to be perfect for implementing ViewModels.

Example

To demonstrate, let’s reimplement this hook-based component:

const Age = () => {
  const [age, setAge] = useState(0);
  const handleGetOlder = () => {
    setAge(age => age + 1);
  };

  return (
    <div>
      <label>My age is {age}</label>
      <button onClick={handleGetOlder}>Get Older</button>
    </div>
  );
};

export default Age;

This component has internal state and business logic. It couples data and behavior to the view layer, which may look succinct in a blog post, but in practice can lead to component bloat.

Let’s reimplement the component using some of the things we’ve learned.

// ViewModel.ts
class ViewModel {
  @observable age = 0;

  @action.bound handleGetOlder() {
    this.age++;
  }
}
// View.tsx
const View = observer(({ vm }: { vm: ViewModel }) => {
  return (
    <div>
      <label>My age is {vm.age}</label>
      <button onClick={vm.handleGetOlder}>Get Older</button>
    </div>
  );
});
// index.tsx
const Age = () => {
  const vm = useMemo(() => new ViewModel(), []);
  return <View vm={vm} />;
};

export default Age;

We’ve split our component into three pieces:

  • ViewModel: The ViewModel: an encapsulation of the data and behavior.
  • View: The pure view: an inner component that is purely presentational.
  • Age: A wrapper component that supplies the ViewModel.

Note: We don’t have to provide a wrapper component to hide our ViewModel from the outside. In fact, exposing it may be a prudent choice when designing a component’s API. This idea will be explored further in a future blog post.

Each piece now has a clear, concise purpose. Let’s explore a refactor, supposing we want to split out the label and button into their own components.

Each sub-component of the view accepts the same props: just the ViewModel itself. No more prop wrangling. Also, since the view is pure, you no longer have to worry about state or logic when refactoring components.

// View.tsx
const Label = observer(({ vm }: { vm: ViewModel }) => {
  return <label>My age is {vm.age}</label>;
});

const Button = observer(({ vm }: { vm: ViewModel }) => {
  return <button onClick={vm.handleGetOlder}>Get Older</button>;
});

const View = observer(({ vm }: { vm: ViewModel }) => {
  return (
    <div>
      <Label vm={vm} />
      <Button vm={vm} />
    </div>
  );
});

Testing component logic is dead simple, too. To test the original hook-based component, we’d have to use React Testing Library (or similar) because the behavior was baked into the presentation. Now, all we need to do is test the logic itself:

// ViewModel.test.ts
let vm: ViewModel;

beforeEach(() => {
  vm = new ViewModel();
});

it('should have the correct initial age', () => {
  expect(vm.age).toEqual(0);
});

it('should have the correct age after getting older', () => {
  vm.handleGetOlder();
  expect(vm.age).toEqual(1);
});

It becomes much simpler to test the UI itself, as well: just use the ViewModel to programmatically evoke the desired state and test the output.

Conclusion

Like the 1960s Studebaker Avanti in the not-too distant future of Gattaca, retro MVVM concepts can have their time in the sun again. View purity in React can be achieved by introducing MobX ViewModels, which decouple business logic from presentation, improving readability, maintainability, and testability of components.

At OpenPhone, we use MobX ViewModels to power some of our most complex and real-time components. Let’s talk on Mastodon if that sounds interesting.