Преимущества связки Mobx + React
Управлением состоянием в больших приложениях
React предлагает свою универсальную систему управления состоянием компонентов посредством свойства компонента this.state и метода this.setState(). Однако по мере роста приложения, увеличения количества вложенных компонентов поддерживать код становится всё труднее. Состояние приложения может стать неконсистентным. Бизнес-логика размазывается по иерархии компонентов. Требуется писать всё больше коллбэков для передачи на верхний уровень информации о состоянии компонентов. Компоненты приложения становится труднее переиспользовать.
Поэтому для управления состоянием часто используют библиотеки в дополнение к React. Тогда, если пользоваться архитектурными терминами MVC, MVVM, React отвечает за View (отображение) плюс общий каркас приложения. Работа с Model строится с помощью библиотеки управления состоянием. Например, такой, как Mobx.
Упрощённая схема её работы представлена на следующем рисунке:

Я расскажу, как сделать простое приложение, используя связку React + Mobx, и какие преимущества это нам даёт. При построении приложения примем следующую концепцию: каждый компонент или группа компонентов делятся на "глупые" (только отображение) и "умные" (state, хендлеры, сайд-эффекты). Приложение состоит из вложенных компонентов, уровень вложенности может быть любой. В листингах кода внимание в первую очередь уделяется работе с данными.
Сквозное подключение данных
Mobx предлагает простой способ подключения данных. Причём каждый из компонентов получает только те данные, которые нужны:
import React from "react";
import ReactDOM from "react-dom";
import {Provider} from "mobx-react";
import App from "./components/App";
import mainStore from "./stores/mainStore";
import optionsStore from "./stores/optionsStore";
// для IE11
require("es6-object-assign").polyfill();
const stores = {
mainStore,
optionsStore,
ButtonStore : mainStore.ButtonStore,
FioStore : mainStore.FioStore,
EmailStore : mainStore.EmailStore
};
ReactDOM.render((
<Provider {...stores}>
<App />
</Provider>
), document.getElementById('reactContainer'));
На верхнем уровне мы используем механизм Provider. В его свойствах перечисляются те хранилища, которые будут востребованы в разных компонентах в приложении. Сразу отметим, в примере выше некоторые хранилища представлены как свойства главного хранилища. Подробнее об этом будет сказано в последнем разделе статьи.
Теперь рассмотрим подключение данных в компонентах приложения:
import React from "react";
import {inject, observer} from "mobx-react";
@inject("mainStore, optionsStore")
@observer
export default class App extends React.Component {
constructor(props) {
super(props);
};
render() {
<div>
{this.props.optionsStore.appName}
</div>
}
}
Здесь присутствуют две важных сущности Mobx – @inject и @observer.
@inject внедряет только то хранилище (из представленных на верхнем уровне через Provider), которое будет нужно непосредственно в этом компоненте. Разные части нашего приложения используют разные хранилища, которые мы перечисляем в inject через запятую. Хранилища доступны в компоненте через this.props.yourStoreName.
@observer производит подписку на изменение данных в хранилищах. Сам механизм подписки скрыт в библиотеке Mobx, мы лишь декларируем, что хотим знать о том, что данные в этих хранилищах изменились. Таким образом, мы избавились от подписок на изменение событий (как это требовалось бы в чистом JS) и от пробрасывания коллбэков в родительские компоненты (если бы мы использовали чистый React). Теперь Mobx отвечает за доставку всех изменений данных прямо в компоненты!
События и реакции
Перейдём к хранилищу одного из компонентов и посмотрим, как обрабатываются пользовательские события:
import {action, autorun, observable} from 'mobx';
import optionsStore from "./optionsStore";
import {
getTarget,
sendStats
} from "../common/heplers";
export default class EmailStore {
constructor() {
autorun(() => {
sendStats();
});
}
@observable params = {
value : "",
disabled : null,
isCorrect : null,
isWrong : null,
onceValidated : false
};
@action bindUserData = (e) => {
if (e) e.preventDefault();
this.params.value = getTarget(e).value;
this.validate(this.params.value);
};
@action validate = (data) => {
if (data && data.match(optionsStore.emailRegexp)) {
this.params.isCorrect = true;
this.params.onceValidated = true;
this.params.isWrong = false;
} else {
this.params.isCorrect = false;
if (this.params.onceValidated) this.params.isWrong = true;
}
};
}
Ещё несколько важных сущностей Mobx:
autorun – используется в том случае, когда нам нужно запустить действия директивно, помимо реакции на изменение данных хранилища. Например, вызвать метод из подключаемой библиотеки с целью отправить статистику.
@observable – объект, за изменением полей которого следит Mobx. Если хотя бы одно из полей объекта изменилось, Mobx доставляет его новое значение компоненту, который мы обернули декораторами @observer и @inject (с указанием именно этого хранилища).
@action – специальный декоратор для обёртывания хендлеров любых событий, которые должны поменять state приложения и/или вызвать сайд-эффекты. В примере выше пользователь вводит значение email, которое мы записываем в поле value observable-объекта params (первое изменение state), а потом валидируем и меняем значения других полей в params. В коде UI компонента @action вызываются так же, как и обычные хендлеры в React:
render() {
return (
<div className="email-input">
<label htmlFor="email">Please type yor email</label>
<input
type="email"
disabled={this.props.disabled}
name="email"
id="email"
value={EmailStore.params.value}
onChange={(e) => EmailStore.bindUserData(e)}
/>
</div>
);
}
Реакции могут быть не только на пользовательские событие, но и на изменение данных.
import {action, computed, get, observable, reaction} from 'mobx';
import optionsStore from "./optionsStore";
import emailStore from "./emailStore";
export default class Reactions {
constructor(props) {
reaction(
() => this.emailAction,
(result) => {
this.userData.emailValue = result.value;
this.userData.emailIsCorrect = result.isCorrect;
if (result.onceValidated) this.makeFormValidated();
}
);
}
@observable userData = {
emailValue : null,
emailIsCorrect : false
}
@action makeFormValidated = () => {
// do some side-effect..
}
@computed get emailAction() {
const p = EmailStore.params;
return {
value : p.value,
isCorrect : p.isCorrect,
onceValidated : p.onceValidated
}
};
}
@computed – декоратор для функций, которые отслеживают изменения в observable-объектах. Важным преимуществом Mobx является то, что отслеживаются только те данные, которые вычисляются непосредственно в этой функции и потом возвращаются в качестве результата. То есть, если в EmailStore.params три перечисленных в блоке @computed return параметра не менялись (value, isCorrect, onceValidated), то @computed не будет производить никаких вычислений. Как видно на этом примере, observable-объекты для @computed могут браться из любого места приложения, в т.ч. из другого хранилища данных.
reaction – инструмент для организации сайд-эффектов на основе изменившегося состояния. Он принимает две функции: первая computed, возвращающая новое вычисленное состояние, вторая – функция с эффектами, которые должны последовать вслед за изменением состояния.
Информация о состоянии доставляется мгновенно
Одним из серьёзных преимуществ библиотеки Mobx является то, что состояние в ней консистентно. Мы помним, что изначально в React изменение состояния this.setState() представляет собой асинхронный вызов. То есть мы не можем точно сказать, когда состояние действительно изменится, мы лишь ставим запрос на изменение состояния в общую очередь.
Mobx, напротив, гарантирует нам, что состояние изменится ровно в тот момент, когда будет дана команда в коде. Это означает, что буквально в следующей строке мы можем использовать уже новое состояние, записанное в объекте @observable:
@action bindUserData = (e) => {
this.params.value = getTarget(e).value;
this.validate(this.params.value);
};
Это касается не только одного компонента, но всех, которые подключены через единый Provider к одному и тому же набору хранилищ.
Коллбэков больше нет
Поскольку заботу об изменении состояния и доставки его до потребителя (то есть компонента) берёт на себя сама библиотека, нам больше не нужны функции-коллбэки, передаваемые через props от родительских компонентов к дочерним, как мы это делали в обычном React. Иерархия вложенности компонентов может быть сколь угодно сложной, и механизм передачи состояния теперь не играет роли при построении архитектуры приложения. Все изменения в другом компоненте, о которых нам нужно знать, могут быть отслежены с помощью @computed. Мы также можем вынести набор часто используемых методов в отдельное хранилище – назовём его Actions – которое может обрабатывать действия и выдавать сайд-эффекты (менять состояние). В некотором смысле у нас теперь единое хранилище для коллбэков, которые больше не привязаны к конкретным компонентом в иерархии приложения. Это также позволяет упростить поддержку кода и избавиться от дублирования функций.
Возможная модель хранилища данных
Поскольку Mobx позволяет не привязывать модель данных к иерархии компонентов UI, мы можем заняться построением удобного хранилища данных сами. Mobx не навязывает какую-то одну модель построения хранилища данных, и оставляет этот вопрос на выбор разработчика. Это означает, что модель хранилища может иметь свою собственную иерархию, либо быть плоской.
На практике с плоской моделью данных взаимодействовать трудно, т.к. нужно согласование взаимодействия компонентов хотя бы на уровне приложения. Конечно, можно писать связи many-to-many. Но тогда в каждом из хранилищ придётся настраивать свои computed и reactions, которые будут ждать изменений в других хранилищах. Также встаёт вопрос порядка инициализации хранилищ.
Поэтому минимально разумной представляется одноступенчатая иерархия вложенности хранилищ данных. Создаётся по одному изолированному хранилищу на каждый обособленный компонент или тип компонентов (например, у нас на странице 10 текстовых полей ввода одного типа, на всех один store с названием InputStore). Оркестрированием работы всего приложения занимается mainStore – хранилище, которое знает обо всех других хранилищах. В начале статьи приведён пример подключения хранилищ через Provider. Видно, что хранилища компонентов представлены как свойства главного хранилища:
const stores = {
mainStore,
optionsStore,
ButtonStore : mainStore.ButtonStore,
FioStore : mainStore.FioStore,
EmailStore : mainStore.EmailStore
};
Кроме того, мы можем подключить статическое хранилище словарей (в примере выше optionsStore), которое будет отдавать заранее известный набор данных и не меняться в процессе работы.
При такой схеме работы мы имеем следующее направление потока данных: компонентные хранилища ничего не знают о других хранилищах, в том числе mainStore, и не вызывают никаких сторонних эффектов. Единственное, что положено компонентному хранилищу – это изменять своё состояние. В качестве исключения такое хранилище может брать данные из хранилища-словаря (в нашем случае optionsStore).
Соответственно, mainStore знает всё о других хранилищах (оно инициализирует их в своём конструкторе), а также слушает через @computed все изменения состояний, необходимых для работы.
При таком подходе mainStore со временем может сильно разрастись, поэтому имеет смысл также разделить его на 3 составных части: собственно mainStore, где будет происходить первоначальная инициализация, а также содержатся все @obvervable и @computed. В отдельную часть можно вынести "библиотеку коллбэков" Actions и "библиотеку хендлеров" Reactions:
// mainStore.js
import {computed, get, observable} from 'mobx';
import optionsStore from "./optionsStore";
import ButtonStore from "./ButtonStore";
import FioStore from "./FioStore";
import EmailStore from "./EmailStore";
import Actions from "./Actions";
import Reactions from "./Reactions";
class mainStore {
constructor() {
this.ButtonStore = new ButtonStore();
this.FioStore = new FioStore();
this.EmailStore = new EmailStore();
this.Actions = new Actions(this);
this.Reactions = new Reactions(this);
}
@observable userData = {
name : "",
surname : "",
email : ""
};
@observable buttons = {
sendData : {
disabled : true
}
};
@computed get emailAction() {
const p = this.EmailStore.params;
return {
value : p.value,
isCorrect : p.isCorrect,
onceValidated : p.onceValidated
}
};
}
// Actions.js
import {action, get} from 'mobx';
export default class Actions{
constructor(props) {
this.props = props;
this.ButtonStore = props.ButtonStore;
this.FioStore = props.FioStore;
this.EmailStore = props.EmailStore;
this.fillBlocks();
};
@action fillBlocks = () => {
// do something
};
@action hideElement = (el) => {
// do something
}
}
// Reactions.js
import {reaction, get} from 'mobx';
export default class Reactions{
constructor(props) {
this.props = props;
this.ButtonStore = props.ButtonStore;
this.FioStore = props.FioStore;
this.EmailStore = props.EmailStore;
reaction(
() => props.emailAction,
(result) => {
props.userData.emailValue = result.value;
props.userData.emailIsCorrect = result.isCorrect;
if (result.onceValidated) props.Actions.makeFormValidated();
}
);
};
}
Общая схема хранилищ данных выглядит следующим образом:

Для более продвинутого управления данным можно попробовать библиотеку mobx-state-tree.
- Впервые опубликовано: Mail Cloud Solutions blog