В статье описывается еще одна вариация архитектуры для React приложений, появившаяся в результате написания собственного класса для управления состоянием компонентов. Основное назначение предложенного подхода — упростить и ускорить разработку приложений на React.

В React приложениях данные обычно можно разделить на 2 вида: данные самого приложения (хранятся в store) и данные, которые используются конкретным компонентом при отрисовке (хранятся в state).
На недавнем проекте я пришел к решению отделить состояние компонента от него, реализовав собственный класс для работы с состоянием. В этот класс я вынес код, отвечающий за обновление компонента, подписку на хранилища и получение данных из них. В самом классе store я реализовал возможность подписки на его изменения, тем самым избавившись от глобальных событий.

Поначалу на проекте я использовал Reflux, но быстро почувствовал следующие недостатки:
1) Постоянно приходилось писать код для объявления новых actions и подписки на них.
2) Проблемы из-за того, что изменение свойств внутри объекта в state не вызывает обновление компонента.

В решении, к которому я пришел, этих недостатков нет. Так как оно хорошо показало себя на реальном проекте, то я решил поделиться им в данной статье.

Преимущества предложенного архитектурного подхода


1) Меньше «бесполезного» кода, который требуется писать во Flux подходах (объявление actions, подписка на конкретные actions, и другое).

2) Избавляет от необходимости использовать системы событий.

3) Автоматическое обновление компонентов при обновлении данных в хранилищах приложения.

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

К данному подходу я написал небольшую библиотеку. Также я добавил справочник API и примеры.

Краткое описание примеров:

1) simple-list — пример получения данных из хранилища.
2) list-with-server — тот же пример, но с получением данных с сервера и сохранением их в хранилище.
3) form-editing — пример формы с редактированием, биндингом, серверной валидацией и сохранением данных на сервере.
4) filters — пример фильтрации. Также демонстрирует, что параметры фильтрации в одном компоненте не влияют на результат фильтрации в другом компоненте такого же типа.

В своей библиотеке я использую ObjectPath. ObjectPath позволяет записывать значения с указанием пути к нужному полю (свойству) объекта, а также считывать и проверять существование свойства внутри объекта, хранящего вложенные объекты.

В библиотеке помимо самого подхода реализованы:

1) работа с серверной валидацией. Эта часть писалась для работы с Django и может не подойти к проектам с серверной валидацией на других фреймворках.
2) частичная и полная отмена изменений – возможность сбросить выбранные поля в состоянии компонента к значениям в хранилище.
3) решение проблемы обновления компонентов при изменении свойств внутри объекта в state.

Описание подхода


Основная идея – вместо использования в компонентах стандартных состояний, использовать свой объект-состояние для упрощения контроля его изменений. Стандартный state компонента имеет большой недостаток – он не обновляет компонент при изменениях вложенных свойств объекта.

Также в данном подходе в приложении есть несколько хранилищ данных. Эти хранилища могут обновляться из компонента и извне (например, при получении новых данных по сети). Хранилище обновляет UIStates (так названы состояния, вынесенные из компонента), подписанные на него. UIState считывает данные хранилища и сохраняет их копию в себе. Перед сохранением в себе, UIState может как-то обработать полученные данные. После получение данных их хранилища, UIState обновляет компонент. Компонент может считывать и записывать значения в UIState.


Схема архитектуры. Стрелками показано направление потока данных.

В данной архитектуре есть следующие основные сущности:
UIState (UI стейт/состояние) – класс, используемый вместо state компонента. Назван так, потому что в нем хранятся данные, используемые компонентом для отображения в текущий момент времени. У каждого компонента создается свой экземпляр такого класса. Может подписываться на изменения различных хранилищ, а также может хранить и любые другие данные, как и обычный state компонента.

При изменениях вызывает setState({}). Также при ручном изменении отдельных полей можно указывать, обновлять компонент или нет.

Данный класс уже реализован и в большинстве случаев не нужно писать свой. При необходимости можно написать свой, а также наследоваться от дефолтного.

Store (хранилище) – класс, для хранения данных приложения. Например, для хранения данных текущего пользователя и для хранения списка товаров. Под каждый вид данных свое хранилище. Данные в хранилище отличаются от данных в UIState до тех пор, пока не вызван метод для сохранения данных в хранилище, после которого обновятся UI состояния, подписанные на него.

Он также уже реализован и в большинстве случаев не нужно писать свой. При необходимости можно написать свой, а также наследоваться от дефолтного.

Обычно на изменение хранилища подписываются UIStates, но ничто не мешает подписаться другим классам.

Stores – Простой класс, хранящий список всех хранилищ приложения.

Отношение классов:
Store – UIState: много ко многим. Как хранилище может иметь много подписчиков, так и UIState может быть подписан на множество хранилищ.
UIState – Сomponent: один к одному. Но, также Сomponent может иметь несколько UIStates. Хотя, в этом нет необходимости.

Примеры использования


Полноценные работающие примеры можно посмотреть по уже указанной ранее ссылке. В примерах используется JSX Control Statements для циклов в JSX коде:

Пример 1 (Простой список с обновлением данных)
1. Одной строчкой создаем нужное хранилища данных

import {DefaultStore} from 'ui-states';
class Stores{
    //в параметре конструктора DefaultStore указываем идентификатор/ключ хранилища, по которому к нему можно будет обращаться.
    static customers = new DefaultStore('customers'); 
}

2. В компоненте создаем UIState со своей моделью данных и подпиской на нужные хранилища

import Stores from './stores.js'
//импорт из библиотеки дефолтного класса, который отвечает за работу с UI состоянием компонента
import {DefaultUIState} from 'ui-states' 

class List extends Component
    componentWillMount() {
        //создаем UIState для данного компонента. 
        //В первом параметре передаем ссылку на компонент. 
        //Во втором – объект, который хранит дополнительные данные состояния компонента, не связанные с хранилищем. 
        //Это практически то же, что и обычный state в компонентах. В него нужно помещать все, что нужно хранить в состоянии
        //компонента, но не нужно хранить в store. Обращаться к этим данным можно через объект model: 
        //this.uiState.model.myField.
        //В третьем параметре передается массив объектов с параметрами. В каждом таком объекте хранится ссылка на store и 
        //дополнительные параметры, говорящие о том, как работать с данным хранилищем в текущем UIState.
        this.uiState = new DefaultUIState(this, null, [{store: Stores.customers }]);    
   }
  
   componentWillUnmount() {
       this.uiState.removeState(); //удаляем UIState при демонтировании компонента
   }

   handleClick() {
        //запись/обновление данных в хранилище
        Stores.customers.update({
            items: [
                {id: 1, name: 'Alexey', city: 'Moscow', email: 'alexey@gmail.com'},
                {id: 2, name: 'Andrey', city: 'Bangkok', email: 'andrey@gmail.com'},
                {id: 3, name: 'Anatoly', city: 'Singapore', email: 'anatoly@gmail.com'}
            ]
        });
    }

    render() {
        return (
            <div>
                <button onClick={this.handleClick.bind(this)}>Load data</button>
                <br/><br/>
                //считываем данные из UIState. В функции get указывается путь к нужному свойству в UIState
                <For each="item" index="index" of={ this.uiState.get('customers.items', []) }> 
                    <div key={item.id}>
                        <span>{item.name} </span>
                        <span>{item.city} </span>
                        <span>{item.email}</span>
                    </div>
                </For>
            </div>
        )
    }
}

Все, больше ничего писать не нужно. Хранилище само оповестит подписчиков о своих изменениях. UIState в своем конструкторе сам подписывается на переданные ему хранилища и обновляет компонент. Вся нужная логика написана в 2-х классах: DefaultStore, DefaultUIState. В большинстве случаев их хватает, но при необходимости любой из них можно заменить на свой или унаследоваться от них и расширить их наследников.

Опишу, как нужно выполнять чтение и запись в uiState.

Чтение:
let field1 = this.uiState.store_key.field1;
Либо let field1 = this.uiState.get('store_key.field1');
Если данные хранятся только в state, без использования Store, то данные хранятся в объекте model: let field1 = this.uiState.model.field1.

Запись:
this.uiState.set('store_key.field1', newValue).
Опять же, если данные нужно хранить без использования Store, то используем model: this.uiState.set('model.field1', newValue).

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



1. Создание хранилища

import {DefaultStore} from 'ui-states';
export default class Stores{
    static currentCustomer = new DefaultStore('currentCustomer');
}

2. Класс с сетевой логикой (частичный код)

import Stores from './stores.js'

export default class Network {
    static getCustomer() {
      //Тут какая-нибудь сетевая логика, возвращающая ответ с данными.
      //В данном примере возвращаются данные в следующем формате: 
      //{id: 1, name: 'Alexey', city: 'Moscow', email: 'alexey@gmail.com'}, 
      //Далее сохранение объекта с данными, пришедшими от сервера.
      //Здесь используется replace, а не update, потому-что при update происходит мердж полей объекта из store с новым объектом.
      //При replace старый объект полностью заменяется новым.
      //В данном случае может смениться один customer на другого, поэтому здесь нужно использовать replace.
      // При сохранении объекта или же при получения списка подойдет update.
      Stores.currentCustomer.replace(responceData);
    }
   }

   static saveCustomer(customer) {
      //Тут какая-нибудь сетевая логика, отправляющая данные на сервер и возвращающая response с данными. 
      //В данном примере данные возвращаются в следующем формате:
      //{id: 1, name: 'Alexey', city: 'Moscow', email: 'alexey@gmail.com'}, 

      //Далее обработка полученного результата:
      if (response.ok) {
        Stores.currentCustomer.update(null, responceData);
      }
      else {
        //При ошибке обновляем не customer-а, а данные для его валидации в форме.
        //В данном примере возвращаются данные в следующем формате: 
        //{name: 'errorMessage', city: 'ErrorMessage', email: 'errorMessage'}. 
        //В  возвращаемом объекте присутствуют только поля с ошибками валидации.
        Stores.currentCustomer.update(null, responceData); 
      }
    }

    //Сохранение только одного поля в форме. Во втором параметре передается путь к нужному свойству в хранилище.
    static saveCustomerCity(customer, pathInStore) {
      //Тут какая-нибудь сетевая логика, отправляющая данные на сервер и возвращающая response с данными.
      //В данном примере возвращаются данные в следующем формате: { city: 'Moscow'}, 
      if (response.ok) {
        Stores.currentCustomer.updateField(responseData.city, pathInStore);
      }
      else {
        //В данном примере возвращаются данные в следующем формате:  {city:'errorMessage''}
        Stores.currentCustomer.updateField(null, responseData.city, pathInStore);
      };
    }
}

3. Компонент с формой

import Stores from './../stores.js'
import Network from './../network.js' //класс с сетевыми методами
import {DefaultUIState } from 'ui-states'
//Компонент - обертка над input. В нем также присутствуют поля для вывода названия поля и текста ошибки
import InputWrapper from './input-wrapper.js' 

export default class CustomerForm extends Component {
    componentWillMount() {
        this.uiState = new DefaultUIState(this, null, [{store:  Stores.currentCustomer }]);
        Network.getCustomer();
    }

    componentWillUnmount() {
        this.uiState.removeState();
    }

    handleCancel() {
        this.uiState.cancelAllChanges(); //отмена всех изменений в UIState. Значение станут такими же, как в store
    }

    handleSave() {
        Network.saveCustomer(this.uiState.currentCustomer);
    }

    handleCancelCity() {
        this.uiState.cancelChangesByPath('city', mainStore); //отменяет изменения только в поле ‘city’
    }

    handleSaveCity() {
        Network.saveCustomerCity(this.uiState.currentCustomer);
    }

    //Преобразование различных данных в props для input, чтобы не копировать один и тот же код
    mapToInputProps(field) {
        return {
            type: "text",
            name: field,
            parentUiState: this.uiState,
            pathToField: 'currentCustomer', //полный путь к полю получиться следующий:  this.uiState.currentCustomer
            pathToValidationField: 'currentCustomer.validationData'  //полный путь к полю получится следующий:
                                                                     //this.uiState.currentCustomer.validationData
        };
    }

    render() {
        return (
            <div>
                <form>
                    <InputWrapper label="Customer name" {...this.mapToInputProps('name')}/>
                    <InputWrapper label="Customer city" {...this.mapToInputProps('city')}/>
                    <InputWrapper label="Customer email" {...this.mapToInputProps('email')}/>
                </form>
                <button onClick={this.handleCancel.bind(this)}>Cancel</button>
                <button onClick={this.handleSave.bind(this)}>Save</button>
                <br/><br/>
                <button onClick={this.handleCancelCity.bind(this)}>Cancel city only</button>
                <button onClick={this.handleSaveCity.bind(this)}>Save city only</button>
            </div>
        )
    }
}

Касательно передачи uiState в InputWraper:
Передавать родительское состояние компонента, и уж тем более менять его в дочерних компонентах в большинстве случаев не стоит. Биндинг, как в данном случае, скорее исключение, чем практика, так как получается очень удобно и к тому же не вызывается перерисовка всей формы.

Недостатки предложенного подхода/библиотеки


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

Комментарии (21)


  1. keksmen
    12.09.2016 14:42

    А можете мне, тугодуму, объяснить чем описанный вами подход отличается от множества подходов, уже описанных на данном ресурсе?


    1. strannik_k
      12.09.2016 16:13

      1) Вместо глобальных событий используется подписка напрямую на хранилище. К тому же не используется подписка на каждое действие. Например, вместо действий addI, delete, update используется только одно — update, т.к. обычно этого достаточно.
      2) Вместо стандартного state пишется свой класс, в котором реализовывается общий функционал для управления состоянием компонента. В моем случае было достаточно написать один такой класс для всех компонентов в приложении.
      Во flux, redux используется стандартный state. В Redux дерево состояний хранится в хранилище и у компонентов нет локального состояния.

      Для сравнения подходов — при добавлении очередного функционала, всегда приходится помимо компонента и вызова действия писать код для:
      В Flux – создания нового класса store с подпиской на действия, создания действий с описанием их сигнатуры, задания имен действий, регистрации действий в диспетчере, подписки компонента на изменения хранилища.
      В Reflux – создания нового класса store с подпиской на действия, задания имен действий, подписки компонента на изменения хранилища.
      В Redux – создания действий с описанием их сигнатуры, задания имен действий, reducer, описания как преобразовать state в props.
      В UIstates (мой подход) – создания экземпляра хранилища, подписки компонента на изменения хранилища.

      Есть и другие, вроде baobab, mobx, но с ними я не знаком.


      1. bjornd
        12.09.2016 16:42
        +1

        В Redux дерево состояний хранится в хранилище и у компонентов нет локального состояния.

        Это неправда. Хороший разбор на эту тему по ссылке https://github.com/reactjs/redux/issues/1287. Если вкратце, то данные которые нужно сохранить на сервер или иметь к ним доступ из нескольких несвязанных компонентов, — в глобальный стор, все остальное в локальный state компонента.


        1. strannik_k
          12.09.2016 18:36

          Спасибо за поправку!
          Я вот так понимаю, что если в компоненте данные берутся из глобального стора, то state в таком компоненте использовать не стоит. Если стор не используется, то лучше использовать state.
          На тему третьего случая – когда один компонент должен использовать глобальный стор и локальное состояние, я не так давно задавал вопрос: https://toster.ru/q/300204
          Хотелось бы услышать ваше мнение.


          1. bjornd
            12.09.2016 18:52
            +2

            State и store использовать в одном компоненте можно.


            1. stancom
              15.09.2016 20:29
              +1

              И часто нужно когда оптимизируем приложение


          1. VolCh
            16.09.2016 21:06

            Классический пример использования и того и другого — формы с кнопками сохранить и отменить. При создании компонента формы, часть стопа копируется в стейт и пользователь работает со стейтом, пока не нажмет «сохранить»


  1. nuit
    12.09.2016 14:51

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


  1. bjornd
    12.09.2016 16:37
    +1

    Стандартный state компонента имеет большой недостаток – он не обновляет компонент при изменениях вложенных свойств объекта.

    We don't mutate the state © Dan Abramov, автор Redux. Можно только порекомендовать побольше почитать про Redux, Immutable.js, Object.assign и object spread оператор.


    1. strannik_k
      12.09.2016 16:47

      Первоначально я пробовал использовать Object.assign и object spread, но их легко забыть где-нибудь использовать. К тому же надо постоянно минимум 2 строки писать — сначала обновить поля объекта, а потом сделать его копию через Object.assign или object spread.
      Про Immutable.js слышал, но еще не работал с ним. У меня задача решена использованием object-path и последующим вызовом перерисовки компонента. Возможно Immutable.js было бы лучше использовать.


      1. bjornd
        12.09.2016 17:18

        надо постоянно минимум 2 строки писать

        да нет, всего одну: {...oldState, newProperty: 42}, и забыть spread-оператор в таком случае очень сложно


        1. strannik_k
          12.09.2016 17:33

          Я про сложные объекты говорил. Например, когда в state хранится массив с объектами и нужно изменить какое-нибудь свойство в одном из объектов массива.


          1. bjornd
            12.09.2016 17:55

            [...a.slice(0, i), {...o, p: 42}, ...a.slice(i+1)]
            

            Но это хорошо только для случаев когда изменение свойста одного объекта влияет каким-то образом на рендеринг всего списка. В противном случае стоит денормализовать стор. Например хранить список id объектов в массиве, а сами объекты в хэш-таблице с доступом по id.


            1. strannik_k
              12.09.2016 18:39
              +1

              [...a.slice(0, i), {...o, p: 42}, ...a.slice(i+1)]
              
              Спасибо, уж лучше в 2 строчки :)


              1. bjornd
                12.09.2016 18:43

                Всегда можно вынести в отдельный метод


                1. strannik_k
                  12.09.2016 19:45

                  И он будет подходить под любые структуры данных или под большинство из них? Или же для каждого случая свой метод писать?
                  Если использовать указание пути к свойству в объекте, что-то вроде updateState('[3].innerObject.innerProp', newValue), тогда можно сделать отдельный универсальный метод.


          1. wert_lex
            12.09.2016 18:43

            Всё уже придумано в этих ваших хаскелях — линзы в помощь. Лонгрид тут: https://medium.com/@dtipson/functional-lenses-d1aba9e52254


  1. bjornd
    12.09.2016 17:13

    del


    1. Staltec
      12.09.2016 18:02
      +2

      Посмотрите в сторону MobX. Там вообще ничего лишнего. Вы имеете дело только с observer-компонентами и store-моделями свойства которых объявлены как observalle. Всё чистенько и без бардака свойственного Redux в больших проектах. С ростом функциональности приложения, его сложность сопровождения практически не растёт.


  1. DisaPadla
    13.09.2016 17:37

    Стандартный state компонента имеет большой недостаток – он не обновляет компонент при изменениях вложенных свойств объекта.


    ComponentWillReceiveProps для чего? Или я что-то не так понял. Вдобавок Immutable.js и все будет обновляться. Если я не правильно понял, поправьте, пожалуйста


    1. strannik_k
      13.09.2016 17:43

      ComponentWillReceiveProps для чего? Или я что-то не так понял. Вдобавок Immutable.js и все будет обновляться. Если я не правильно понял, поправьте, пожалуйста

      Тогда мне нужно было как-то разобраться с проблемой. Выбор был, либо использовать Immutable.js, либо ObjectPath. Я выбрал ObjectPath.

      Наверное, вы имели ввиду shouldComponentUpdate, т.к. ComponentWillReceiveProps при setState не вызывается.
      На тот момент, когда проект был еще в начальной стадии разработки, то ли баг был в реакте, то ли еще чего. В общем, на то время, если написать методы жизненного цикла в компоненте и унаследоваться от него, то методы не вызывались. А в каждом втором компоненте писать shouldComponentUpdate как-то не хотелось.