React предлагает универсальную систему управления состоянием компонентов посредством свойства компонента this.state и метода this.setState(). Однако по мере роста приложения и увеличения количества вложенных компонентов поддерживать код становится труднее. Расскажу, как решить эту проблему, используя React в связке с Mobx.

Почему React лучше использовать с Mobx

Если использовать только React, состояние приложения может стать неконсистентным. Бизнес-логика размазывается по иерархии компонентов. Требуется писать больше колбэков для передачи на верхний уровень информации о состоянии компонентов. Компоненты приложения становится труднее переиспользовать.

Поэтому для управления состоянием часто используют библиотеки в дополнение к React. Тогда, в терминах описания архитектуры, React в нашем MVC- или MVVM-приложении отвечает за View (отображение) плюс общий каркас приложения. А работа с Model строится с помощью библиотеки управления состоянием. Например, такой как Mobx.

Упрощенная схема работы библиотеки представлена на следующем рисунке:

Я расскажу, как сделать простое приложение, используя связку React + Mobx, и какие преимущества это дает.

При построении приложения примем следующую концепцию: каждый компонент или группа компонентов делятся на «глупые» (только отображение) и «умные» (state, хендлеры, сайд-эффекты). Приложение состоит из вложенных компонентов, уровень вложенности может быть любой. В листингах кода внимание в первую очередь уделяется работе с данными.

Сквозное подключение данных в Mobx

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 доставляется мгновенно

Одним из серьезных преимуществ библиотеки Mobx является то, что состояние в ней консистентно. Мы помним, что изначально в React изменение состояния this.setState() представляет собой асинхронный вызов. То есть мы не можем точно сказать, когда состояние действительно изменится, мы лишь ставим запрос на изменение состояния в общую очередь.

Mobx, напротив, гарантирует, что состояние изменится ровно в тот момент, когда будет дана команда в коде. Это означает, что буквально в следующей строке мы можем использовать уже новое состояние, записанное в объекте @observable:

@action bindUserData = (e) => { 
    this.params.value = getTarget(e).value;    
    this.validate(this.params.value);    
};

Это касается не только одного компонента, но всех, которые подключены через единый Provider к одному и тому же набору хранилищ.

C Mobx + React колбэков больше нет

Поскольку заботу об изменении состояния и доставке его до потребителя (то есть компонента) берёт на себя сама библиотека, нам больше не нужны функции-колбэки, передаваемые через props от родительских компонентов к дочерним, как мы это делали в обычном React.

Иерархия вложенности компонентов может быть сколь угодно сложной, и механизм передачи состояния теперь не играет роли при построении архитектуры приложения. Все изменения в другом компоненте, о которых нам нужно знать, могут быть отслежены с помощью @computed.

Мы также можем вынести набор часто используемых методов в отдельное хранилище — назовём его Actions — которое может обрабатывать действия и выдавать сайд-эффекты (менять состояние). В некотором смысле у нас теперь единое хранилище для колбэков, которые больше не привязаны к конкретным компонентам в иерархии приложения. Это также позволяет упростить поддержку кода и избавиться от дублирования функций.

Возможная модель хранилища данных в Mobx

Поскольку 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 со временем может сильно разрастись, поэтому имеет смысл также разделить его на три составных части: собственно 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();
            }
        );
    };    
}

Общая схема хранилищ данных выглядит следующим образом:

Что дает применение React в связке с Mobx

  1. Мы избегаем неконсистентности состояния приложения, Бизнес-логика не размазывается по иерархии компонентов.
  2. Не нужно писать больше колбэков для передачи на верхний уровень информации о состоянии компонентов.
  3. Компоненты приложения проще переиспользовать.