В 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 коде:
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).
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)
nuit
12.09.2016 14:51Всегда мечтал вынести состояние анимации волны из компонент реализующих материал кнопочки. Это ведь невероятно упростит разработку приложений!
bjornd
12.09.2016 16:37+1Стандартный state компонента имеет большой недостаток – он не обновляет компонент при изменениях вложенных свойств объекта.
We don't mutate the state © Dan Abramov, автор Redux. Можно только порекомендовать побольше почитать про Redux, Immutable.js, Object.assign и object spread оператор.strannik_k
12.09.2016 16:47Первоначально я пробовал использовать Object.assign и object spread, но их легко забыть где-нибудь использовать. К тому же надо постоянно минимум 2 строки писать — сначала обновить поля объекта, а потом сделать его копию через Object.assign или object spread.
Про Immutable.js слышал, но еще не работал с ним. У меня задача решена использованием object-path и последующим вызовом перерисовки компонента. Возможно Immutable.js было бы лучше использовать.bjornd
12.09.2016 17:18надо постоянно минимум 2 строки писать
да нет, всего одну: {...oldState, newProperty: 42}, и забыть spread-оператор в таком случае очень сложноstrannik_k
12.09.2016 17:33Я про сложные объекты говорил. Например, когда в state хранится массив с объектами и нужно изменить какое-нибудь свойство в одном из объектов массива.
bjornd
12.09.2016 17:55[...a.slice(0, i), {...o, p: 42}, ...a.slice(i+1)]
Но это хорошо только для случаев когда изменение свойста одного объекта влияет каким-то образом на рендеринг всего списка. В противном случае стоит денормализовать стор. Например хранить список id объектов в массиве, а сами объекты в хэш-таблице с доступом по id.strannik_k
12.09.2016 18:39+1
Спасибо, уж лучше в 2 строчки :)[...a.slice(0, i), {...o, p: 42}, ...a.slice(i+1)]
bjornd
12.09.2016 18:43Всегда можно вынести в отдельный метод
strannik_k
12.09.2016 19:45И он будет подходить под любые структуры данных или под большинство из них? Или же для каждого случая свой метод писать?
Если использовать указание пути к свойству в объекте, что-то вроде updateState('[3].innerObject.innerProp', newValue), тогда можно сделать отдельный универсальный метод.
wert_lex
12.09.2016 18:43Всё уже придумано в этих ваших хаскелях — линзы в помощь. Лонгрид тут: https://medium.com/@dtipson/functional-lenses-d1aba9e52254
bjornd
12.09.2016 17:13del
Staltec
12.09.2016 18:02+2Посмотрите в сторону MobX. Там вообще ничего лишнего. Вы имеете дело только с observer-компонентами и store-моделями свойства которых объявлены как observalle. Всё чистенько и без бардака свойственного Redux в больших проектах. С ростом функциональности приложения, его сложность сопровождения практически не растёт.
DisaPadla
13.09.2016 17:37Стандартный state компонента имеет большой недостаток – он не обновляет компонент при изменениях вложенных свойств объекта.
ComponentWillReceiveProps для чего? Или я что-то не так понял. Вдобавок Immutable.js и все будет обновляться. Если я не правильно понял, поправьте, пожалуйстаstrannik_k
13.09.2016 17:43ComponentWillReceiveProps для чего? Или я что-то не так понял. Вдобавок Immutable.js и все будет обновляться. Если я не правильно понял, поправьте, пожалуйста
Тогда мне нужно было как-то разобраться с проблемой. Выбор был, либо использовать Immutable.js, либо ObjectPath. Я выбрал ObjectPath.
Наверное, вы имели ввиду shouldComponentUpdate, т.к. ComponentWillReceiveProps при setState не вызывается.
На тот момент, когда проект был еще в начальной стадии разработки, то ли баг был в реакте, то ли еще чего. В общем, на то время, если написать методы жизненного цикла в компоненте и унаследоваться от него, то методы не вызывались. А в каждом втором компоненте писать shouldComponentUpdate как-то не хотелось.
keksmen
А можете мне, тугодуму, объяснить чем описанный вами подход отличается от множества подходов, уже описанных на данном ресурсе?
strannik_k
1) Вместо глобальных событий используется подписка напрямую на хранилище. К тому же не используется подписка на каждое действие. Например, вместо действий addI, delete, update используется только одно — update, т.к. обычно этого достаточно.
2) Вместо стандартного state пишется свой класс, в котором реализовывается общий функционал для управления состоянием компонента. В моем случае было достаточно написать один такой класс для всех компонентов в приложении.
Во flux, redux используется стандартный state. В Redux дерево состояний хранится в хранилище и у компонентов нет локального состояния.
Для сравнения подходов — при добавлении очередного функционала, всегда приходится помимо компонента и вызова действия писать код для:
В Flux – создания нового класса store с подпиской на действия, создания действий с описанием их сигнатуры, задания имен действий, регистрации действий в диспетчере, подписки компонента на изменения хранилища.
В Reflux – создания нового класса store с подпиской на действия, задания имен действий, подписки компонента на изменения хранилища.
В Redux – создания действий с описанием их сигнатуры, задания имен действий, reducer, описания как преобразовать state в props.
В UIstates (мой подход) – создания экземпляра хранилища, подписки компонента на изменения хранилища.
Есть и другие, вроде baobab, mobx, но с ними я не знаком.
bjornd
Это неправда. Хороший разбор на эту тему по ссылке https://github.com/reactjs/redux/issues/1287. Если вкратце, то данные которые нужно сохранить на сервер или иметь к ним доступ из нескольких несвязанных компонентов, — в глобальный стор, все остальное в локальный state компонента.
strannik_k
Спасибо за поправку!
Я вот так понимаю, что если в компоненте данные берутся из глобального стора, то state в таком компоненте использовать не стоит. Если стор не используется, то лучше использовать state.
На тему третьего случая – когда один компонент должен использовать глобальный стор и локальное состояние, я не так давно задавал вопрос: https://toster.ru/q/300204
Хотелось бы услышать ваше мнение.
bjornd
State и store использовать в одном компоненте можно.
stancom
И часто нужно когда оптимизируем приложение
VolCh
Классический пример использования и того и другого — формы с кнопками сохранить и отменить. При создании компонента формы, часть стопа копируется в стейт и пользователь работает со стейтом, пока не нажмет «сохранить»