Как организовать общее состояние в react-приложениях без использования библиотек (и зачем нужен mobx)

в 21:21, , рубрики: javascript, mobx, React, ReactJS, redux

Cразу небольшой спойлер — организация состояния в mobx ничем не отличается от организации общего состояния без использования mobx на чистом реакте. Ответ на закономерный вопрос зачем тогда собственно этот mobx нужен вы найдете в конце статьи а пока статья будет посвящена вопросу организации состояния в чистом в react-приложении без каких-либо внешних библиотек.

Как организовать общее состояние в react-приложениях без использования библиотек (и зачем нужен mobx) - 1

Реакт предоставляет способ хранить и обновлять состояние компонентов используя свойство state на инстансе компонента класса и метод setState. Но тем не менее среди реакт сообщества используются куча дополнительных библиотек и подходов для работы с состоянием (flux, redux, redux-ations, effector, mobx, cerebral куча их). Но можно ли построить достаточно большое приложение с кучей бизнес-логики c большим количеством сущностей и сложными взаимосвязями данных между компонентами используя только setState? Есть ли необходимость в дополнительных библиотеках для работы с состоянием? Давайте разберемся.

Итак у нас есть setState и который обновляет состояние и вызывает перерендер компонента. Но что если одни и те же данные потребуются многим компонентам никак не связанных между собой? В официальной доке реакта есть раздел "lifting state up" с подробным описанием — мы просто поднимаем состояние к общему для этих компонентов предку передавая через пропсы (и через промежуточные компоненты при необходимости) данные и функции для его изменения. На маленьких примерах это выглядит разумным но реальность такова что в сложных приложениях возможно очень много зависимостей между компонентами и тенденция выносить состояния в общий для компонентов предка приводит к тому что все состояние будет выносится все выше и выше и в итоге окажется в рутовом компоненте App вместе с логикой обновления этого состояния для всех компонентов. В итоге setState будет встречаться только для обновления локальных для компонента данных или в корневом компонента App в котором будет сосредоточена вся логика.

Но можно ли хранить обрабатывать и рендерить состояние в реакт приложении не используя ни setState, ни какие-то дополнительные библиотеки и обеспечить общий доступ к этим данным из любых компонентов?

На помощь нам приходят самые обычные javascript-объекты и определенные правила их организации.

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

Для начала введем объект который будет хранить глобальные данные которые относятся ко всему приложению в целом — (это могут быть настройки стилей, локализации, размеров окна и т.д.) в единственном объекте AppState и просто вынесем этот объект в отдельный файл.

// src/stores/AppState.js
export const AppState = {
 locale: "en",
 theme: "...",
 ....
}

Теперь в любом компоненте можно заимпортить и использовать данные нашего стора.

import AppState from "../stores/AppState.js"

const SomeComponent = ()=> (
 <div> {AppState.locale === "..." ? ... : ...} </div>
)

Идем дальше — практически у каждого приложения есть сущность текущего юзера (пока неважно как он создается или приходит от сервера и т.д) поэтому также в состоянии нашего приложения будет некий объект-синглтон юзера. Его можно также вынести в отдельный файл и тоже импортировать а можно хранить сразу внутри объекта AppState. А теперь главное — нужно определить схему сущностей из которых состоит приложение. В терминах базы данных это будут таблицы со связями one-to-many или many-to-many причем вся эта цепочка связей начинается от главной сущности юзера. Ну а в нашем случае объект юзера просто будет хранить массив других объектов-сущностей-сторов где каждый объект-стор в свою очередь хранить массивы других сущностей-сторов.

Вот пример — есть бизнес-логика которая выражается как "юзер может создавать/редактировать/удалять папки, в каждой папке проекты, в каждом проекте задачи и в каждой задаче подзадачи" (получается что-то вроде менеджера задач) и в схема состояния будет выглядеть примерно так:

export const AppStore = {
  locale: "en",
  theme: "...",
  currentUser: {
     name: "...",
     email: ""
     folders: [
       {
        name: "folder1", 
        projects: [
           {
             name: "project1",
             tasks: [
                 {
                   text: "task1",
                   subtasks: [
                     {text: "subtask1"},
                     ....
                   ]
                 },
                 ....
             ]
           },
          .....
        ]
       },
       .....
     ]
  }
}

Теперь рутовый компонент App может просто заимпортить этот объект и отрендерить какую-то информацию о юзере, а дальше может передать объект юзера компоненту дашборда

 ....
<Dashboard user={appState.user}/> 
 ....

а тот сможет отрендерить список папок

 ...
<div>{user.folders.map(folder=><Folder folder={folder}/>)}</div>
 ...

а каждый компонент папки выведет список проектов

 ....
<div>{folder.projects.map(project=><Project project={project}/>)}</div>
 ....

а каждый компонент проекта может вывести список задач

 ....
<div>{project.tasks.map(task=><Task task={task}/>)}</div>
 ....

и наконец каждый компонент задачи может отрендерить список подзадач передав нужный объект компоненту подзадачи

 ....
<div>{task.subtask.map(subtask=><Subtask subtask={subtask}/>)}</div>
 ....

Естественно на одной странице никто не будет выводить все задачи всех проектов всех папок, они будут разбиты по сайдпанелям (например для списка папок), по страницам и т.д но общая структура примерно такая — родительский компонент рендерит вложенный компонент передав в качестве пропса объект с данными. Надо отметить важный момент — любой объект (например объект папки, проекта, задачи) не хранится внутри состояния какого-либо компонента — компонент просто получает его через пропсы как часть более общего объекта. И например когда компонент проекта передает дочернему компоненту Task объект задачи (<div>{project.tasks.map(task=><Task task={task}/>)}</div>) то благодаря тому что объекты хранится внутри единого объекта всегда можно изменить этот объект задачи снаружи — например AppState.currentUser.folders[2].projects[3].tasks[4].text = "edited task" и после чего вызвать обновление рутового компонента (ReactDOM.render(<App/>) и таким образом мы получим актуальное состояние приложения.

Дальше допустим мы хотим при клике по кнопке "+" в компоненте Task создать новую подзадачу. Все просто

 onClick = ()=>{
   this.props.task.subtasks.push({text: ""});
   updateDOM()
 } 

поскольку компонент Task получает в качестве пропса объект задачи и этот объект не хранится внутри его состояния а является частью глобального стора AppState (то есть объект task хранится внутри массива task более общего объекта project а тот в свою очередь часть объекта юзера а юзер уже хранится внутри AppState) и благодаря этой связности после добавиления нового объекта задачи в массив subtasks можно вызвать обновление рутового компонента и тем самым актуализировать и обновить дом для всех изменений данных (неважно где они произошли) просто вызвав функцию updateDOM которая в свою очередь просто выполняет обновление рутового компонента.

export function updateDOM(){
  ReactDom.render(<App/>, rootElement);
}

Причем не имеет значения какие данные каких частей AppState и из каких мест мы меняем (например можно пробросить через пропсы объект папки через промежуточные компоненты Project и Task компоненту Subtask а тот может просто обновить название папки (this.props.folder.name = "new name") — благодаря тому что компоненты получают данные через пропсы обновление рутового компонента обновит все вложенные компоненты и актуализирует все приложение.

Теперь попробуем добавить немного удобств работы со стором. В примере выше можно заметить что создавая каждый раз новый объект сущности (например project.tasks.push({text: "", subtasks: [], ...}) если у объекта есть много свойств с дефолтными параметрами то придется каждый раз всех их перечислять и можно ошибиться и забыть что-то т.д. Первое что приходит на ум это вынести создание объекта в функцию где будут присвоены дефолтные поля и заодно их переопределить новыми данными

function createTask(data){
 return {
   text: "",
   subtasks: [],
   ...
   //many default fields
   ...data
 }
}

но если взглянуть с другой стороны то эта функция является конструктором определенной сущности и на эту роль отлично подходят классы javascript

class Task {
  text: "";
  subtasks: [];
  constructor(data){
    Object.assign(this, data)
  }
}

и тогда создание объекта будет просто создаем инстанса класса c возможностью переопределить некоторые дефолтные поля

onAddTask = ()=>{
 this.props.project.tasks.push(new Task({...})
}

Дальше можно заметить что аналогичным образом создавая классы для объектов-проектов, юзеров, подзадач мы получим дублирование кода внутри конструктора

constructor(){
 Object.assign(this,data)
}

но мы можем воспользоваться наследованием и вынести этот код в конструктор базового класса.

class BaseStore {
 constructor(data){
  Object.update(this, data);
 }
}

Дальше можно заметить что каждый раз когда обновляем какое-то состояние мы вручную меняем поля объекта

user.firstName = "...";
user.lastName = "...";
updateDOM();

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

class Task {
 update(newData){
   console.log("before update", this);
   Object.assign(this, data);
   console.log("after update", this);
 }
} 
////
user.update({firstName: "...", lastName: "..."})

Ну и для того чтобы не дублировать код в каждом классе также вынесем этот метод update в базовый класс.

Теперь можно заметить что когда мы обновляем какие-то данные нам вручную приходится вызывать метод updateDOM(). Но можно для удобства выполнять это обновление автоматически -каждый раз когда происходит вызов метода update({...}) базового класса.
В итоге получается что базовый класс будет выглядеть примерно так

class BaseStore {
 constructor(data){
  Object.update(this, data);
 }
 update(data){
   Object.update(this, data);
   ReactDOM.render(<App/>, rootElement)
 }
}

ну а чтобы при последовательном вызове метода update() не происходило лишних обновлений можно отложить обновление компонента на следующий цикл событий

let  TimerId = 0;
class BaseStore {
 constructor(data){
  Object.update(this, data);
 }
 update(data){
   Object.update(this, data);
   if(TimerId === 0) { 
     TimerId = setTimeout(()=>{
       TimerId = 0;
       ReactDOM.render(<App/>, rootElement);
    })
   }
 }
}

Дальше можно постепенно наращивать функционал базового класса — например чтобы не приходилось помимо обновления состояния еще вручную каждый раз отправлять запрос на сервер можно при вызове метода update({..}) в фоне отсылать запрос. Можно организовать канал лайв-обновлений по вебсокетам добавив учет каждого созданного объекта в глобальной хеш-мапе вообще не меняя никак компоненты и работу с данными.

Можно еще много чего наворотить но хочу отметить одну интересную тему — очень часто передавая нужному компоненту объект с данными (например когда компонента проекта рендерит компонент задачи —

<div>{project.tasks.map(task=><Task task={task}/>)}</div>

самому компоненту задачи может потребоваться какая-то информация которая не хранится непосредственно внутри задачи а находится в родительском объекте.

Допустим нужно покрасить все задачи в цвет который хранится в проекте и является общим для всех задач. Для этого компоненту проекта нужно передать помимо пропса задачи заодно и свой пропс проекта <Task task={task} project={this.props.project}/>. А если вдруг нужно покрасить задачу в цвет общий для всех задач одной папки то придется уже передавать объект текущей папки от компонента Folder компоненту Task пробрасывая через промежуточный компонент Project.
Появляется хрупка зависимость что компонент должен знать о том что требуется его вложенным компонентам. Причем возможность контекста реакта хоть и упростит передачу через промежуточные компоненты все равно потребует описание провайдера и знание о том какие данные нужны для дочерних компонент.

Но самой главной проблемой является то что при каждой правке дизайна или изменении хотелок заказчика когда компоненту потребуется новая информация — придется менять вышестоящие компоненты либо пробрасывая пропсы либо создавая провайдеров контекста. Хотелось бы чтобы компонент получая через пропсы объект с данными мог как-то обратиться к любой части нашего состояния приложения. И тут как нельзя кстати подходит возможность javascript (в отличие от всяких функциональных языков вроде elm или иммутабельных подходов вроде redux) — чтобы объекты могли хранить циклические ссылки друг на друга. В данном случае объект задачи должен иметь поле task.project со ссылкой на объект родительского проекта в котором он хранится а объект проекта в свою очередь должен иметь ссылку на объект папки и т.д до самого рутового объекта AppState. Таким образом компонент, как бы глубоко не находился, всегда может по ссылке пройтись по родительским объектам и достать всю нужную информацию и не нужно прокидывать ее через кучу промежуточных компонентов. Поэтому вводим правило — каждый раз создавая какой-то объект нужно добавить ссылку на родительский объект. Например теперь создание новой задачи будет выглядеть так

 ...
 const {project} = this.props;
 const newTask = new Task({project: this.props.project})
 this.props.project.tasks.push(newTask);

Дальше, при увеличении бизнес-логики можно заметить что болерплейт связанный с поддержкой обратных ссылок (например присваивание ссылки на родительский объект при создании нового объекта или например при переносе проекта из одной папки в другую потребуется не только обновление свойства project.folder = newFolder а и удаление себя из массива проектов предыдущей папки и добавление в массив проектов новой папки) начинает повторяться и его также можно вынести в базовый класс чтобы при создании объекта достаточно было указать родителя — new Task({project: this.porps.project}) а базовый класс автоматически добавил бы новый объект в массив project.tasks и также при переносе задачи в другой проект достаточно было бы просто обновить поле task.update({project: newProject}) и базовый класс автоматически бы удалил задачу из массива задач предыдущего проекта и добавил в новый. Но это уже потребует декларирование связей (например в статических свойствах или методах) чтобы базовый класс знал какие поля обновлять.

Заключение

Вот таким нехитрым образом используя только js-объекты мы пришли к выводу что можно получить все удобства работы с общим состоянием приложения не привнося в приложение зависимость от внешней библиотеки для работы с состоянием.

Появляется вопрос, зачем тогда нужны библиотеки для управления состоянием и в частности mobx?

Дело в том что в описанном подходе организации общего состояния когда используя обычные нативные "ванильные" js oбъекты (или объекты классов) есть один большой недостаток — при изменении небольшой части состояния или даже одного поля будет происходить обновление или "перерендер" компонентов которые никак не связаны и не зависят от данной части состояния.
А на больших приложениях с "жирным" ui это приведет к тормозам потому что реакт просто не успеет рекурсивно сравнить вирутальный дом всего приложения учитывая что помимо сравнения на каждый перерендер будет генерироваться каждый раз новое дерево объектов описывающую верстку абсолютно всех компонентов.

Но эта проблема, несмотря на важность, чисто техническая — есть аналогичные реакту vitual dom библиотеки которые лучше оптимизируют перерендер и могут увеличить предел компонентов.

Есть более эффективные техники обновления дома нежели создания нового дерева виртуального дома и последующий рекурсивный проход сравнения с предыдущим деревом.

И наконец есть библиотеки которые пытаются решить проблему медленного обновления через другой подход — а именно — затрекать какие части состояния с какими компонентами связаны и при изменении каких-то данных вычислить и обновить только те компоненты которые зависят от этих данных а остальные компоненты не трогать. Такой библиотекой является и redux но требует совершенно иного подхода к организации состояния. А вот библиотека mobx наоборот не вносит ничего нового и мы можем получить ускорение перерендера практически не меняя ничего в приложении — достаточно только добавить к полям класса от которых могут зависеть компоненты декоратор @observable а к компонентам которые рендерят эти поля еще один декоратор @observer и осталось выпилить только ненужный код обновления рутового компонента в методе update() нашего базового класса и мы получим полностью работающее приложение но теперь изменение части состояния или даже одного поля обновит только те компоненты которые подписаны (обращаются внутри метода render()) на конкретное поле конкретного объекта состояния.

Автор: bgnx

Источник

* - обязательные к заполнению поля