DEV Community

Alexandrovich Dmitriy
Alexandrovich Dmitriy

Posted on • Edited on

MobX with MVVM makes Frontender's life much easier than Redux does

In this article, I would like to discuss how well the MVVM pattern is suitable for developing applications on React. Also, I'm going to describe what advantages there may appear when developing using MobX and the MVVM pattern in comparison with Redux. Stock up on coffee, this is gonna be a long read.

In general, if you analyze the articles that compare MobX and Redux, you will notice several frequently repeated theses:

  • "Modx is easier to learn than Redux"
  • "MobX is more productive than Redux"
  • "You need to write less code in MobX than in Redux"
  • However, "Scaling with Redux is easier than with MobX", and therefore "MobX is suitable for small applications, and Redux for large ones"
  • "Debugging Redux is easier than MobX"
  • "Redux's community is much bigger than MobX's"
  • "MobX gives too much freedom"

Here are a couple of examples of such articles

It's silly to deny that in comparison with Redux, MobX's community is not so big. For example, the number of downloads at npmjs.com for Redux is 8 times bigger than for MobX. However, I can hardly agree with the other disadvantages.

It seems to me, that debugging problems are either purely subjective, or appear as a result of poor application architecture. For example, I have never experienced any problems with the MobX debug.

The scaling point also looks strange for me. The bigger the application becomes with Redux, the more selectors it has, the more logic in the reducers, the more middleware, the slower it becomes. No matter how the code with Redux is optimized, performance will definitely fall when scaling. On the other hand, MobX, in terms of performance, can expand quite painlessly. Therefore, I assume that the authors of such articles talk about problems in scaling in the context of writing new code. In this case, it is again a problem of poor architecture.

Well, and here we are. There's a reason, why I highlighted the problem of excessive freedom in MobX. This problem is real. There are several ways to create and use stores. For example, you can use them in the component by importing, by injection, or create them in the component. There are incredibly many approaches to using MobX in React. And MobX doesn't even try to recommend any one "good" approach. And due to the fact that it's "easy to learn", this problem is only getting worse, since, having quickly learned the basic concepts of MobX, some developers believe that they definitely know exactly how to make up the architecture.

But the problems with the architecture is not directly related to MobX. It's weird to blame a gun if you shot your leg. The problem is that developers didn't choose any particular approach to using this tool. But it is not necessary to work out the approach from scratch at all - the existing concepts works very well with creating architecture. And as you probably guessed, in this article I will try to show how the MVVM pattern can sufficiently form the strictness, the lack of which is mentioned in such articles.

What is the MVVM pattern?

The main advantage of the MVVM (Model View ViewModel) pattern is the separation of GUI development and logic.

Look at the short example


import { action, observable, makeObservable } from 'mobx';
import { view, ViewModel } from '@yoskutik/react-vvm';

class CounterViewModel extends ViewModel {
  @observalbe count = 0;

  constructor() {
    super();
    makeObservable(this);
  }

  @action increate = () => {
    this.count++;
  };
}

const Counter = view(CounterViewModel)(({ viewModel }) => (
  <div>
    <span>Counter: {viewModel.count}</span>
    <button onClick={() => viewModel.increate()}>increase</button>
  </div>
));


Enter fullscreen mode Exit fullscreen mode

And I'm talking about a complete separation of logic and appearance. In the MVVM approach, your smart components can consist of JSX code exclusively. With such a short example, I just wanted to interest you. But before describing the benefits, let's outline how MVVM can be used in React applications.

MVVM in the React applications

For myself, I have identified only 4 rules for MVVM implementation:

  • Every viewmodel must be linked to the view and, therefore, when the view is unmounted, the viewmodel must "die";
  • Each viewmodel can (and often will) be a store;
  • This store can be used not only in the component that created the viewmodel (i.e. inside the view), but also in all child components of the view;
  • The view and the viewmodel know about each other's existence.

What is the awareness of the view and the viewmodel about each other? In the React application, the view will be created by React. Therefore, the view have to be responsible for creating the viewmodel. In turn, the viewmodel knows about view's props and is able to process different logic at different stages of the view's life cycle.

And now about benefits

Surely, you have wondered: "But why should the viewmodel die when the view becomes unmounted?". The answer is quite simple. If the component is no longer in the virtual DOM, there is probably no need to store data and methods to display this component. This optimization allows you to free up application memory when some pieces of data are not being used.

And this is just the beginning.

Context API and Data Abstraction

The third "rule" of my implementation says that the viewmodel can be used at any depth inside the view. It's clear that this is about the Context API. And it is quite possible to use it together with the MVVM pattern.

Using MVVM with Context API


import { makeObservable, observable } from 'mobx';
import { childView, view, ViewModel } from '@yoskutik/react-vvm';

class SomeViewModel extends ViewModel {
  @observable field1 = '';

  constructor() {
    super();
    makeObservable(this);
  }

  doSomething = () => {};
}

// ChildView doesn't create a viewmodel and should be located
// somewhere inside a View. Thus, it can interract with SomeViewModel.
const ChildView = childView<SomeViewModel>()(({ viewModel }) => (
  <div>
    <span>{viewModel.field1}</span>
    <button onClick={viewModel.doSomething}>Click in a child of  view</button>
  </div>
));

const View = view(SomeViewModel)(({ viewModel }) => (
  <div>
    <span>{viewModel.field1}</span>
    <button onClick={viewModel.doSomething}>Click in a view</button>

    <ChildView />
  </div>
));


Enter fullscreen mode Exit fullscreen mode

There is a fairly clear analogy with Redux, which exists exclusively on the Context API. However, there is a significant difference between the Redux and MobX approach with MVVM.

Look at these graphics
The difference between Redux and MobX with MVVM
Each node in the graph in the diagram is a React component.

In the Redux approach, one store is created, and this store is available throughout whole application. In the MVVM approach, a store is created only for the view. And in this case, only 3 stores are created. At the same time, the blue store and the components using it do not know about the existence of the yellow and red stores, they cannot read their data in any way or somehow affect on them.

Unlike Redux, MVVM gives real data abstraction. For example, the data needed by one page can be visible only inside this page, and something that the modal window needs will be visible only to it.

Nested views

Now you could see some contradiction. I said that the view model should be accessible to any children of the view, and the diagram clearly shows that the red nodes have their own view model, but not the "yellow" one.

In such a case, an additional concept was introduced - the parent viewmodel. The parent viewmodel for "red" node will be "yellow" one.

And in the code, the use of the parent viewmodel may look like this


import { view, ViewModel } from '@yoskutik/react-vvm';

class ViewModel1 extends ViewModel {
  doSomething = () => {};
}

class ViewModel2 extends ViewModel<ViewModel1> {
  onClick = () => {
    this.parent.doSomething();
  };
}

// View2 should be located somewhere View1. Thus, ViewModel1 will
// become a parent viewmodel for View2
const View2 = view(ViewModel2)(({ viewModel }) => (
  <button onClick={viewModel.onClick} />
));

const View1 = view(ViewModel1)(({ viewModel }) => (
  <div>
    <View2 />
  </div>
));


Enter fullscreen mode Exit fullscreen mode

But it is also interesting that the parent viewmodel can even be an interface. If a certain view can be used inside different views, whose viewmodels implement some single interface, then you can specify it as the type of the parent viewmodel.

Example of using a parent viewmodel with typing as an interface


import { view, ViewModel } from '@yoskutik/react-vvm';
import { ISomeViewModel } from './ISomeViewModel';

class ViewModel1 extends ViewModel implements ISomeViewModel { ... }

class ViewModel2 extends ViewModel implements ISomeViewModel { ... }

// The type of parent viewmodel will be ISomeViewModel
class ViewModel3 extends ViewModel<ISomeViewModel> { ... }

const View3 = view(ViewModel3)(({ viewModel }) => (
  <div />
));

// View3 can be used in different views
const View1 = view(ViewModel1)(({ viewModel }) => (
  <View3 />
));

const View2 = view(ViewModel2)(({ viewModel }) => (
  <View3 />
));


Enter fullscreen mode Exit fullscreen mode

It probably sounds complicated. But in practice it's quite simple. For example, let's say there is some widget in the project that can appear on several pages, and at the same time depend in the same way on these pages. All pages will have their own view models, but the widget display rules will be the same for each page. And these rules can be called the interface of the parent viewmodel.

Complete separation of logic and appearance

As I wrote above, in MVVM, the separation of logic and display can reach a new level. I incredibly like the concept that smart components can consist exclusively of JSX code. I think this greatly simplifies the analysis of React components.

Creating handlers in a viewmodel

Let's start with a simple one. Event handlers like onClick, onInput, etc. by definition of MVVM, can be declared in the viewmodel. In the very first example in the viewmodel, I created the increase function, which I then called in the component. But I could also name the function as onClick, then it would be possible to use it directly in the component.



const Counter = view(CounterViewModel)(({ viewModel }) => (
  <div>
    <span>Counter: {viewModel.count}</span>
    <button onClick={viewModel.onClick}>increase</button>
  </div>
));


Enter fullscreen mode Exit fullscreen mode

By the way, such a declaration of handlers has a pleasant effect - all functions inside the viewmodel are memoized, which means they don't change when view renders. So you don't need to worry about using useCallback and other similar hooks when creating handlers in the viewmodel.

Viewmodel knows about the view's props

Even if event handlers somehow depend on the view's props, they can still be declared inside a viewmodel, because viewmodels know about view's props.

Example of using props in event handlers


import { FC } from 'react';
import { view, ViewModel } from '@yoskutik/react-vvm';

type WindowProps = {
  title: string;
  onClose: () => void;
}

class WindowViewModel extends ViewModel<unknown, WindowProps> {
  onCloseClick = () => {
    // do something else
    this.viewProps.onClose();
  };
}

export const Window: FC<WindowProps> = view(WindowViewModel)(({ viewModel, title }) => (
  <div className="window">
    <h1>{title}</h1>
    <button onClick={viewModel.onCloseClick}>close</button>
  </div>
));


Enter fullscreen mode Exit fullscreen mode

And it also seemed logical to me to make the viewProps field observable. In this way, you can create reactions to changes in certain props. And such reactions are much easier to read for me than the reactions created in useEffect. Although, probably, this is a subjective opinion.

Example of a reaction with useEffect


import { FC, useCallback, useEffect } from 'react';

type WindowProps = {
  title: string;
  state: 'warn' | 'error';
  onClose: () => void;
}

export const Window: FC<WindowProps> = ({ title, state, onClose }) => {
  const onCloseClick = useCallback(() => {
    // do something
    onClose();
  }, [onClose]);

  useEffect(() => {
    console.log(state);
  }, [state]);

  return (
    <div className={`window window--${state}`}>
      <h1>{title}</h1>
      <button onClick={viewModel.onCloseClick}>close</button>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

Example of a reaction with MVVM


import { FC } from 'react';
import { view, ViewModel } from '@yoskutik/react-vvm';

type WindowProps = {
  title: string;
  state: 'warn' | 'error';
  onClose: () => void;
}

class WindowViewModel extends ViewModel<unknown, WindowProps> {
  protected onViewMounted() {
    this.reaction(() => this.viewProps.state, state => {
      console.log(state);
    });
  }

  onCloseClick = () => {
    // do something else
    this.viewProps.onClose();
  };
}

export const Window: FC<WindowProps> = view(WindowViewModel)(({ viewModel, title, state }) => (
  <div className={`window window--${state}`}>
    <h1>{title}</h1>
    <button onClick={viewModel.onCloseClick}>close</button>
  </div>
));


Enter fullscreen mode Exit fullscreen mode

Also due to the fact that viewProps is observable, there is an additional nice effect. If nested components will somehow depend on their view's props, then when updating the view's props, they will automatically trigger rerender for them.

Less dependence on lifecycle hooks

I already started talking about hooks in the previous block. There is no longer a need to create reactions to certain objects via useEffect. But useEffect is needed not only for reactions - it can also be used to handle the state of the component lifecycle. For example, when mounting, unmounting or updating. And all these stages of the life cycle may well be processed inside the viewmodel.

Example


import { observable, makeObservable } from 'mobx';
import { view, ViewModel } from '@yoskutik/react-vvm';

class ComponentViewModel extends ViewModel {
  @observable data = undefined;

  constructor() {
    super();
    makeObservable(this);
  }

  // For example, you can use this function instead of
  // useLayoutEffect(() => { ... }, []);
  protected onViewMountedSync() {
    fetch('url')
      .then(res => res.json())
      .then(res => this.doSomething(res));
  }

  // Or this function partially instead of
  // useEffect(() => { ... });
  protected onViewUpdated() {
    console.log('Some functionality after component updated');
  }

  doSomething = (res: any) => {};
}

export const Component = view(ComponentViewModel)(({ viewModel }) => (
  <div>
    {viewModel.data}
  </div>
));


Enter fullscreen mode Exit fullscreen mode

Number of props

This is a small but very nice bonus. In smart components, the number of props can be reduced to a minimum. In my last project, on average, each smart component had no more than 3 props.

This is achieved due to the fact that there is no need for drilling props in MVVM. Child components of the view can directly access their viewmodel. If they or their view depends on the state of the parent view, they can refer to the parent field. If there is a dependency on the view's props, then you can refer to the viewProps field. In other cases, of course, you still have to pass props directly, but even such a small opportunity allows you to significantly reduce the number of props. Which affects not only the simplicity of code analysis, but also the performance of the application, because memoized components will have to check fewer props when updating.

Compared to Redux

To make you even more aware of the beauty of MobX with MVVM, I'll try to compare this approach with Redux.

Look at the example below. I've written the same logic as in the MVVM hooks example, but written in Redux.

Example with Redux


import { FC, useEffect, useLayoutEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IReduxStore } from '../../store';
import { doSomething } from './slice';

export const Component: FC = () => {
  const data = useSelector((state: IReduxStore) => state.slice.data);
  const dispatch = useDispatch();

  useLayoutEffect(() => {
    fetch('url')
      .then(res => res.json())
      .then(res => dispatch(doSomething(res)));
  }, []);

  useEffect(() => {
    console.log('Some functionality after component render');
  });

  return (
    <div>
      {data}
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

In this example, you can see the power of a complete separation of logic and appearence. In the example with Redux, the component is still responsible for updating the state. The actions are declared somewhere else, but they must be used inside the component. In the MVVM example, the view does not call hooks and does not process data in any way. The view only uses it.

What is also interesting is that there is the same amount of code in both examples. In fact, there is a bit more code in MVVM, but the code in the constructor can be avoided (more on this below). But at the same time, you still need to create a slice in Redux, and therefore you have to write more code in Redux.

In general, the amount of code is a separate pleasant bonus in comparison with MobX and Redux. I'm sure, developers who use Redux often think about how much repetitive code they have to write. Of course, Redux Toolkit improved the situation, but it didn't fully get rid of the repetitive code. You need to call a hook to get the store value; you need to call a hook to get the dispose function; you need to specify specific names for actions and/or slices; etc.

And typing. Redux Toolkit has simplified it quite a lot. But to me, it still looks like a crutch. Getting a store type based on the result of a function that returns it; creating a reducer with a certain type using an additional generic function; or the need to set the store type in each useSelector call. I can't say that these are pleasant solutions to use.

In the MobX, it is rarely necessary to think about typing. I created a viewmodel class and then use its object. And TypeScript will arrange all the typing itself.

At the same time, in Redux it is necessary to think about memoization. The amount of useMemo and useCallback per line of code in Redux may differ dramatically from this amount in MobX with MVVM.

And the props. In my practice, drilling of props is quite common in Redux, at least within nesting in 1-3 layers. And, as I wrote above, there are fewer such problems in MVVM.

But that's not all

I think you can agree, that MVVM already looks pretty good. But I decided to go even further and "level up" my MVVM implementation a little.

Automatic call of disposers

The MobX documentation says that you should always call dispose of reactions. This rule helps to avoid memory leaks problems. And following this rule is not very convenient. But not in the MVVM approach.

So, when do the reactions created in the viewmodel cease to be necessary for us? Considering that the viewmodel should die after view unmounting, then in most cases at the moment when the view is unmounted. Therefore, you can add an automatic call at this moment. In this case, reactions can be created using small add-on functions inside the viewmodel. At the same time, the interface and typing of these functions will not differ from their analogues in MobX.

Example of creating reactions with automatic dispose


import { intercept, makeObservable, observable, observe, when } from 'mobx';
import { ViewModel } from '@yoskutik/react-vvm';

export class SomeViewModel extends ViewModel {
  @observable field = 0;

  constructor() {
    super();
    makeObservable(this);

    // You can use this alias to create reaction
    this.reaction(() => this.field, value => this.doSomething(value));

    // You can also use this alias to create autorun
    this.autorun(() => {
      this.doSomething(this.field);
    });

    // And for other types of reactions you can use
    // addDisposer function

    // observe
    this.addDisposer(
      observe(this, 'field', ({ newValue }) => this.doSomething(newValue))
    );

    // intercept
    this.addDisposer(
      intercept(this, 'field', change => {
        this.doSomething(change.newValue);
        return change;
      }),
    );

    // when
    const w = when(() => this.field === 1);
    w.then(() => this.doSomething(this.field));
    this.addDisposer(() => w.cancel());
  }

  doSomething = (field: number) => {};
}


Enter fullscreen mode Exit fullscreen mode

Flexible configuration

The MVVM approach can be flexibly customized. For example, my implementation allows you to customize how the viewmodel is created, and also allows you to assign a wrapper component for all views and childviews. And these 2 settings are much more powerful than you might think. Of course, these settings can be used for debugging, but this is not their main purpose.

For example, you can make the call of makeObservable automatic, so you don't have to call it in each viewmodel separately.

Automatic makeObservable call


import { makeObservable, observable } from 'mobx';
import { configure, ViewModel } from '@yoskutik/react-vvm';

configure({
  vmFactory: VM => {
    const viewModel = new VM();
    makeObservable(viewModel);
    return viewModel;
  },
});

class SomeViewModel extends ViewModel {
  // And now field1 will become observable. There's no need to call makeObservable.
  @observable field1 = 0;

  // However, in the constructor, the field is not yet observable, so
  // it is better to add reactions to onViewMounted or onViewMountedSync
  protected onViewMounted() {
    this.reaction(() => this.field1, () => {
      // do something
    });
  }
}


Enter fullscreen mode Exit fullscreen mode

You can create a viewmodel in such a way that DI (Dependency Injection) pattern can be added to your project. And for me, this is a very nice bonus. Thanks to DI, you can create small independent stores that can be used in different parts of your project. And instead of depending on a virtual tree, with DI you can build dependencies in an absolutely free way.

Example with DI


import { computed, makeObservable, observable } from 'mobx';
// It's not necessary to use tsyringe, you can use and DI library
import { injectable, container, singleton } from 'tsyringe';
import { configure, ViewModel } from '@yoskutik/react-vvm';

configure({
  vmFactory: VM => container.resolve(VM),
});

// This is an example of small independent store
@singleton()
class SomeOuterClass {
  @observable field1 = 0;

  constructor() {
    makeObservable(this);
  }

  doSomething = () => {
    // do something
  };
}

@injectable()
class SomeViewModel extends ViewModel {
  @computed get someGetter() {
    return this.someOuterClass.field1;
  }

  // Thanks to DI we can use classes by simply set them in the constructor
  constructor(private someOuterClass: SomeOuterClass) {
    super();
    makeObservable(this);
  }

  viewModelDoSomething = () => {
    this.someOuterClass.doSomething();
  }
}

// You can also use singloton classes in any spot in your code
const instance = container.resolve(SomeOuterClass);


Enter fullscreen mode Exit fullscreen mode

The possibility of configuring wrappers for each view also seemed to me a very interesting idea. I liked the possibility of using Error Boundary as a wrapper. In this case, all your views will be automatically wrapped in Error Boundary and you will have to think less about error handling during development. It may seem to you that this is a little excessive, but in reality not all components will be a view, but only those that contain a lot of logic.

Example with Error Boundary


import { Component, ReactElement, ErrorInfo } from 'react';
import { configure } from '@yoskutik/react-vvm';

class ErrorBoundary extends Component<{ children: ReactElement }, { hasError: boolean }> {
  static getDerivedStateFromError() {
    return { hasError: true };
  }

  state = {
    hasError: false,
  };

  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error(error, info);
  }

  render() {
    return !this.state.hasError && this.props.children;
  };
}

configure({
  Wrapper: ErrorBoundary,
});


Enter fullscreen mode Exit fullscreen mode

So, is MVVM the ultimate solution to all problems?

Congratulations, you have almost reached the end of the article. But, the aswer in no. Unfortunately, MVVM is not an ultimate solution to all problems.

MVVM allows you to solve many problems. I really like this pattern. But there's one serious problem. There are times when some data is needed in completely disparate parts of the project.

Graph again
graph

Once again, this is a React component graph. I marked components that depends on the same data with yellow.


If such components are relatively close to each other, then it is quite realistic to place the data somewhere in the parent viewmodel. However, with increasing depth, the number of parents used may increase also. Agree, if you have to get data through viewModel.parent.parent.parent.data, then this is completely inconvenient. And if you store in the viewmodel the data that only a couple of children need at a huge depth inside, then the viewmodel will grow incredibly much.

And here we come back to the fact that we need to use some independent stores that are not tied to the virtual tree in any way, and therefore such stores are not affected by the strictness of MVVM in any way.

But there is a solution. And I even think that you probably already guessed what I'm talking about. The DI pattern can impose that strictness. And it fits pretty well with MVVM. And now MVVM + DI looks like a full-fledged solution for large projects, which will allow you to scale the project for a long time. However, I have already written a lot in the article, so I won't write much about DI. If you want, I could do this in my next article.

The end

Thank you for your time. Feel free to share your opinion in the comments, I will read them with pleasure.

And also, as I said, I have written a tiny library, literally 300 lines of code. You can use it if you want to play with the MVVM pattern. Here are the links: npm, github, documentation. And you can also look at a couple of examples of its use.

Top comments (0)