Вынесение управления состоянием компонентов в пользовательские классы в 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.
Схема архитектуры. Стрелками показано направление потока данных.
В данной архитектуре есть следующие основные сущности:
UIState (UI стейт/состояние) — класс, используемый вместо state компонента. Назван так, потому что в нем хранятся данные, используемые компонентом для отображения в текущий момент времени. У каждого компонента создается свой экземпляр такого класса. Может подписываться на изменения различных хранилищ, а также может хранить и любые другие данные, как и обычный state компонента.
При изменениях вызывает setState ({}). Также при ручном изменении отдельных полей можно указывать, обновлять компонент или нет.
Данный класс уже реализован и в большинстве случаев не нужно писать свой. При необходимости можно написать свой, а также наследоваться от дефолтного.
Store (хранилище) — класс, для хранения данных приложения. Например, для хранения данных текущего пользователя и для хранения списка товаров. Под каждый вид данных свое хранилище. Данные в хранилище отличаются от данных в UIState до тех пор, пока не вызван метод для сохранения данных в хранилище, после которого обновятся UI состояния, подписанные на него.
Он также уже реализован и в большинстве случаев не нужно писать свой. При необходимости можно написать свой, а также наследоваться от дефолтного.
Обычно на изменение хранилища подписываются UIStates, но ничто не мешает подписаться другим классам.
Stores — Простой класс, хранящий список всех хранилищ приложения.
Отношение классов:
Store — UIState: много ко многим. Как хранилище может иметь много подписчиков, так и UIState может быть подписан на множество хранилищ.
UIState — Сomponent: один к одному. Но, также Сomponent может иметь несколько UIStates. Хотя, в этом нет необходимости.
Примеры использования
Полноценные работающие примеры можно посмотреть по уже указанной ранее ссылке. В примерах используется JSX Control Statements для циклов в JSX коде:
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).
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↑
↓
Всегда мечтал вынести состояние анимации волны из компонент реализующих материал кнопочки. Это ведь невероятно упростит разработку приложений!