Upgrade your interaction with MobX. Part 2
Hello everyone! My name is Dima and I don’t like Redux. I love Mobx. And in my collection of articles, I show how you can use MobX to make it even more convenient.
In my last article, I described a structured approach to using MobX. using MVVM and DI patterns. In this article, I’m going to show examples of the use of such an architecture, describing all the possible benefits.
Without a particularly long introduction, right off the bat, I propose to start reviewing the examples from the last article in the order in which they were issued there. In the very first example describes the interaction of entities View, ChildView and ViewModel.
The first example is a regular to-do list – a basic task for a Frontend developer. It has the ability to add new cases, mark them as completed, delete them, and filter them by the search bar.
The root component of the entire application is the component App
.
Let’s take a look at his code.
import React from 'react';
import { makeObservable, observable, action } from 'mobx';
import { injectable } from 'tsyringe';
import { view, ViewModel } from '@yoskutik/mobx-react-mvvm';
import { HBox, VBox } from '@components';
import { LeftPanel } from './LeftPanel';
import { RightPanel } from './RightPanel';
import '../Style.scss';
export type TTodoItem = {
id: string;
title: string;
done: boolean;
};
@injectable()
export class AppViewModel extends ViewModel {
@observable todos: TTodoItem[] = [];
@observable.ref chosenTodo: TTodoItem = null;
constructor() {
super();
makeObservable(this);
}
@action addNewTodo = (title: string) => {
this.todos.push({ id: Math.random().toString(), title, done: false });
};
}
export const App = view(AppViewModel)(({ viewModel }) => (
<VBox style={{ margin: 30 }}>
<h2>TODO List</h2>
<HBox>
<LeftPanel/>
<RightPanel onAdd={viewModel.addNewTodo}/>
</HBox>
</VBox>
));
In the file with the component App
i store both View and ViewModel. I don’t see any problems in such storage, as long as the file size doesn’t get too big. However, if desired, you can, of course, distribute them over different files.
The first thing that catches your eye is the component code itself. App
, which consists only of JSX code, there is absolutely no logic in it, not a single hook call. Using <App/>
and any other View pass prop viewModel
it is forbidden. This prop comes from the HOC function view
.
Now consider the class AppViewModel
. It is not difficult to see that directly App
does not use some fields of its ViewModel. And within the framework of this architecture, this is normal practice. These fields will be further used in ChildView and in other ViewModels.
AppViewModel
has a decorator @injectable
. As part of the interaction between View, ChildView and ViewModel, this decorator doesn’t make much sense. However, it will be required later when adding Services. Decorator @injectable
can be replaced with a decorator @singleton
. It is recommended to use ViewModels with such a decor only in exceptional cases, since the information stored in such ViewModels is not deleted even after the View is removed from the markup.
Consider the next component.
left panel
export const LeftPanel = memo(() => {
const [searchText, setSearchText] = useState('');
return (
<VBox style={{ marginRight: 10 }}>
<SearchTodoField value={searchText} onChange={setSearchText} />
<List searchText={searchText} />
</VBox>
);
});
I specially made it in the form of a regular component. Even though I stick to some particular architecture, there is absolutely no problem in using normal components. MobX, MVVM and DI should only be used when their use can simplify the development process.
Well, now we will consider the most representative component the entire application.
List
import React, { VFC } from 'react';
import { injectable } from 'tsyringe';
import { makeObservable, observable, autorun, action } from 'mobx';
import { view, ViewModel } from '@yoskutik/mobx-react-mvvm';
import { VBox } from '@components';
import type { TTodoItem, AppViewModel } from '../App';
type ListProps = {
searchText?: string;
};
@injectable()
class ListViewModel extends ViewModel<AppViewModel, ListProps> {
@observable.shallow filteredData: TTodoItem[] = [];
constructor() {
super();
makeObservable(this);
autorun(() => {
this.filteredData = this.parent?.todos.filter(it => (
!this.viewProps?.searchText || it.title.toLowerCase().includes(this.viewProps.searchText)
)) || [];
});
}
@action onItemClick = (id: string) => {
this.parent.chosenTodo = this.parent.todos.find(it => it.id === id);
};
}
export const List: VFC<ListProps> = view(ListViewModel)(({ viewModel }) => (
<VBox cls="list">
{viewModel.filteredData.length ? (
viewModel.filteredData.map(it => (
<div key={it.id} onClick={() => viewModel.onItemClick(it.id)}
className={`list__item ${it.done ? 'done' : ''} ${
viewModel.parent.chosenTodo?.id === it.id ? 'chosen' : ''
}`}>
{it.title}
</div>
))
) : (
<div className="list__item">No items in todo list</div>
)}
</VBox>
));
This component stores the main application logic. It must take the data that is stored in AppViewModel
and filter them by the string received in the props of the component.
List
uses the observable fields of its ViewModel, so it must be an observer component. And he is, because by default view
makes the component an observer. Therefore, when changing the field filteredData
component List
will update automatically. Also this component looks at the field of the parent ViewModel chosenTodo
to highlight the entry selected by the user.
List
located somewhere inside the component App
. Therefore, the parent ViewModel for this component will be AppViewModel
. I passed the type of the parent ViewModel as a generic. Also, with a generic, I indicated which components have List
there are props.
Filtering happens automatically inside the function autorun
. fields parent
, viewProps
and parent.todos
are observable, so every time they are updated, the function inside autorun
will be called again, overwriting the value of the field filteredData
. The first time autorun is called, the values parent
and viewProps
will undefined
so when using them, the operator was used ?.
Also ListViewModel
interacts with parent AppViewModel
updating the value chosenTodo
.
You might also notice that one of the imports only imports the type. This was not done by accident. Component App
somewhere inside uses a component List
. Therefore, when importing AppViewModel
cyclic dependencies can arise directly. And they can pretty much spoil the life of the developer. But it’s worth pointing out import type
and there are no such problems.
ChildView: ChosenItem
import { runInAction } from 'mobx';
import React, { VFC } from 'react';
import { childView } from '@yoskutik/mobx-react-mvvm';
import { HBox, VBox } from '@components';
import type { AppViewModel } from '../App';
const Button: VFC<{ text: string; onClick: () => void }> = ({ text, onClick }) => (
<button onClick={() => onClick()} style={{ marginRight: 10 }}>
{text}
</button>
);
export const ChosenItem = childView<AppViewModel>(({ viewModel }) => {
const item = viewModel.chosenTodo;
if (!item) return null;
const onDoneClick = () => {
item.done = !item.done;
};
const oRemoveClick = () => runInAction(() => {
viewModel.todos = viewModel.todos.filter(it => it.id !== item.id);
viewModel.chosenTodo = null;
});
return (
<VBox>
<div className={`list__item ${item.done ? 'done' : ''}`}>{item.title}</div>
<HBox style={{ marginTop: 5 }}>
<Button text={item.done ? 'Undone' : 'Done'} onClick={onDoneClick} />
<Button text="Remove" onClick={oRemoveClick} />
</HBox>
</VBox>
);
});
The last component I would like to talk about isChosenItem
. Its task is to display the selected element of the list, make it possible to mark it as finished and delete it.
This component is a ChildView, that is, it does not create an additional ViewModel, but simply refers to the ViewModel of the View in which it is located.
The logic of this component is stored in the component itself. Of course, in this situation, it would be possible, as in the case of List, to create an additional ViewModel, and keep the logic in it. But for the example of using ChildView, this writing of the component was chosen.
I think it will be important here to indicate why I use the runInAction function. The matter is that I update two observable fields at once in one handler. By updating them in the action, MobX will be able to more optimally build the reaction process.
When should you create a ViewModel?
I showed that in addition to the View / ViewModel connection, the markup can contain ordinary components and a ChildView that store logic in themselves. Therefore, the question may be born, but when is it necessary to separate the logic into a separate ViewModel class? The answer is pretty simple.
When a ViewModel may be required in other child ViewModels
When the ViewModel will need to use Services (more on this in the next article)
When you personally find it convenient to separate logic into a separate ViewModel.
Personally, for myself, I have determined that calling a couple of hooks and creating a couple of functions does not visually clutter up the component code, so in such cases I rarely allocate a separate ViewModel. But when there are more than 3 observable fields, and sometimes even more than 10, and when there are a lot of handler functions, separating the logic into a separate class seems very reasonable to me.
Additionally
In my View and ChildView implementation, I added a wrapper from ErrorBoundary. This was done because in React applications, in the case when one of the components throws an exception, and there is no handling of this error in the form of ErrorBoundary anywhere (in this case, the usual try / catch will not work), the entire React application stops working.
In the first article, I said that View and ChildView do not have to be observers, since they can only use ViewModel’s static fields or methods. And in my implementation, I also added such an opportunity – functions view
and childView
the second parameter takes a boolean parameter indicating the need to turn the component into an observer. By default, this setting is true
.
Summarizing
I will briefly list all possible useful use cases of the described architecture:
Between logic and display, you can draw a clear line in the form of separation into View and ViewModel
For the most part, you can opt out of using context. Instead, you can create a View and ChildView that are able to interact with the parent ViewModel.
In ViewModel’yakh it is possible to hang up reactions to change of received props in View. Moreover, since the View is a memoized object, it will be updated, and therefore pass new props, only when they are changed, and not with every render.
Moreover, if the View has many parameters, and the reaction needs to be hung on a change in one specific field, you can create in the ViewModel
@computed get
which would refer to a specific prop.The developer needs to care less about error handling. If an error occurs on one of the nodes (View or ChildView), only the node itself will disappear from the markup, and not the entire application.
The ViewModel has the ability to persist its state even when the View leaves the markup. This can be useful if, for example, when switching between pages, you do not need to re-request data. To do this, you need to specify a decorator
@singleton
instead of@injectable
.Once again, I repeat that in the general case, this is not recommended. When the ViewModel is a Singleton class, all of its data continues to be stored in the browser’s memory until the page closes. In addition to this, you need to understand that the View using the Singleton ViewModel should be no more than 1 in the entire markup.
However, in my implementation, for the most part, there is a field for the Singleton ViewModel
isActive
by which you can conveniently track whether the View is currently displayed or not.I didn’t show this in my examples, but the ViewModel has the ability to create View mount and unmount handlers –
onViewMount
andonViewUnmount
.
Afterword
In this article, I described only one of the examples of the first article. In the next article I will analyze the remaining 2 examples.