Вынесение управления состоянием компонентов в пользовательские классы в React

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

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

Многие разработчики выносят работу с состоянием компонента отдельно от него. В популярном Redux состояния также убраны из компонентов. Я тоже пришел к данному решению, попутно отказавшись от использования распространенных библиотек вроде Flux, Reflux, Redux. Поначалу я использовал 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.

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

В данной архитектуре есть следующие основные сущности:
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 (
            


//считываем данные из UIState. В функции get указывается путь к нужному свойству в UIState
{item.name} {item.city} {item.email}
) } }

Все, больше ничего писать не нужно. Хранилище само оповестит подписчиков о своих изменениях. 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 (
            


) } }

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

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


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

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

  • 12 сентября 2016 в 14:42

    0

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

  • 12 сентября 2016 в 14:51 (комментарий был изменён)

    0

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

© Habrahabr.ru