Преимущества связки 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.

Теги: