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


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




Реакт предоставляет способ хранить и обновлять состояние компонентов используя свойство 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()) на конкретное поле конкретного объекта состояния.

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

Теги:



Комментарии (37):

  1. vintage
    /#18854263

    Когда приходит ответ от сервера, то нужно заполнить соответствующие сущности, найдя их по идентификаторам. Поэтому стор лучше организовывать в нормализованном виде c перекрёстными ссылками: store.folders['id123'].tasks[0] === store.tasks['id321']

    • bgnx
      /#18855007

      Для того чтобы обновить сущности когда приходит ответ от сервера (также как и при получении обновлений по вебсокетам) когда данные приходят в нормализированном виде — хеше объектов где айдишнику соответствует объект с данными то нужно по айдишнику обновить нужный объект в нашем вложенном дереве объектов связанных ссылками. В статье этот способ не описан в деталях я лишь упомянул что нужно добавить учет каждого созданного объекта в глобальной хеш-мапе. То есть нужно просто в конструкторе базового класса сгенерировать айдишник для нового созданного объекта и закешировать его в глобальном словаре.
      И теперь при получении данных от сервера всегда можно вытащить нужный объект по его айдишнику и обновить нужные в нем данные а сама структура всех данных в состоянии остается в древовидном виде или точнее в виде графа (если учитывать обратные ссылки на родительские объекты).
      То есть нормализация остается только для нужд общения с сервером а для компонент и всего остального данные у нас удобно вложены и ссылаются друг на друга по ссылкам. Это упрощает использование данных как и в шаблонах компонентах так и в обработчиках в отличии от организации данных изначально в нормализованном виде в плоском хеше таблиц как это принято делать используя redux в котором мы теряем возможность обращаться к другим частям состояния просто обращаясь по ссылке.
      Поскольку с нормализованным подходом ссылок на объекты больше нет то связи мы теперь вынуждены моделировать через айдишники, и как следствие каждый раз когда нам нужно обратиться к родительской сущности или вложенным сущностям нам нужно каждый раз вытаскивать объект по его айдишнику из глобального стора. А это неудобно.
      Например, когда нужно узнать рейтинг родительского комментария мы не можем просто написать как comment.parent.rating — нам нужно сначала вытащить объект по айдишнику — AppState.comments[comment.parentId].raiting. А как мы знаем ui может сколь угодно быть разнообразным и компонентам может потребоваться информация о различных частях состояния и такой код вытаскивания по айдишнику на каждый чих легко превращается в некрасивую лапшу и будет пронизывать все приложение. Например, нам нужно узнать самый большой рейтинг у вложенных комментариев, то через ссылки можно просто записать как


      comment.children.sort((a,b)=>b.rating - a.rating))[0]

      а в варианте с айдишниками нужно еще дополнительно замапить айдишники на объеты —


      comment.children.map(сhildId=>AppState.comments[childId]).sort((a,b)=>b.rating - a.rating))[0]

      Или когда требуется достать глубоко вложенные данные (например у объекта комментария нужно узнать имя папки в котором он находится где схема сущностей выглядит как user->folder->project->task->comment) то используя ссылки все просто и лаконично


      comment.task.project.folder.name

      а вот через айдишники это превращается в


      AppState.folders[AppState.projects[AppState.tasks[comment.taskId].projectId].folderId].name

      Ну наконец есть момент производительности — операция получения объекта по ссылке это O(1), а операция вытаскивания объекта по айдишнику это уже O(log(n)) что может сказаться на обработке большого количества данных

      • mayorovp
        /#18855017

        "а операция вытаскивания объекта по айдишнику это уже O(log(n))" — с каких пор?

        • bgnx
          /#18855081

          Я указал сложность для бинарного дерева. В общем случае словарь где айдишнику соответствует объект можно хранить либо в виде дерева и тогда чтобы найти айшишник нужно сделать двоичный поиск на глубину дерева высота которого равна log(n) либо есть еще способ хранить в виде хеш-таблицы когда выделяем массив какого-то размера и вычисляя некое смешение по айдишнику храним объект с ссылкой на объект с данными а поскольку размер массива меньше чем количество всех возможных айдишников то появятся коллизии и новый объект сохранится в виде ссылки от предыдущего объекта (с таким же значением хешфункции) и поиск объекта по айдишнику либо скатится в линейный поиск по связанному списку объектов либо вызвовет аллокацию нового массива побольше (чтобы коллизий было меньше) и перезапись всех элеементов массива заново вычисляя их смещения. В этом случае поиск объекта по айдишнику недетерминирован и только в большом колиестве операций можно оценить сложность но в любом случае это будет медленней чем гарантированное получение объекта по ссылке за O(1)

          • mayorovp
            /#18855089

            Во-первых, все известные мне интерпретаторы javascript используют хеш-таблицы, вот ни разу я не слышал про хранение свойств объекта в дереве…

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

      • Druu
        /#18857711

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

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


        1. состояние прибито к виду — стор должен знать о том, какую вложенность объектов требуют виды, чтобы именно с этой вложенностью объекты и хранились
        2. избыточность и дублирование данных — при хоть сколько-нибудь сложных связях между объектами у вас один и тот же объект будет вложен в много разных других объектов. своевременно все это обновлять и следить за корректностью очень быстро становится сильно сложнее, чем денормализовывать данные на выходе стора.

        • bgnx
          /#18858089

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

          В статье описан как раз противоположеный подход — мы храним данные в сторе моделируя как сущности и связи между ними (например юзер может создавать (изменять, удалять) папки, у них проекты, у проектах задачи и у задачах подзадачи. И соотвественно структура стора будет прямо соответствовать этим сущностям и связям — у объекта User будет храниться массив папок, у каждой папки массив проектов, у каждого проекта массив задач и у каждой задачи массив подзадач. В итоге нигде в структуре стора нет завязки на вид. А структура видов может быть совершенно разная — можно отображение проектов сделать на одной странице с папками (где список папок будет в сайдпанели) можно разнести по разным страницам. Можно как угодно структурировать и моделировать отображение — на структуру состояния это никак не повлияет.


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

          Вот тут я вас не понимаю — объекты в сторе связываются по ссылке — соотвественно если проект отрендерил список задач (<div>{project.tasks.map(task=><Task task={task}/>}</div>) а компонент задачи отрендерил список подзадач точно так же передав подзадачу (<div>{this.props.task.subtasks.map(subtask=><Subtask subtask={subtask}/>}</div>) то изменение задачи в компоненте <Task/> (this.props.task.text = newText) и изменение задачи в компоненте <Subtask/> через ссылку на родительский объект (this.props.subtask.task.text = newText) это изменение одного и того же объекта и соотвественно никакого дублирования и неконсистентности не будет

          • Druu
            /#18859435

            мы храним данные в сторе моделируя как сущности и связи между ними (например юзер может создавать (изменять, удалять) папки, у них проекты, у проектах задачи и у задачах подзадачи.

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


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

            Ну так у вас примитивное дерево. Представьте теперь, что на какой-то Task из списка для конкретного проекта есть ссылки из десятка разных кусков графа. Вы загрузили новый (обновленный) список задач для проекта, ваши действия?

      • vintage
        /#18857847

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


        class Task extends Entity {
            title : string
            folder_id : string
            get folder() {
                return this.store.folders[ this.folder_id]
            }
        }

        Преимущества:


        1. Все объекты можно создавать лениво по мере необходимости.
        2. Так как доступ к данным всегда идёт через реестр, то у нас всегда есть информация нужен ли эти данные хоть кому-нибудь или занятую ими память можно освободить.
        3. Собственно и освободить память легко, ибо нет прямых ссылок кроме как через реестр.
        4. Легко (де)сериализуемое состояние стандартным методами JSON.
        5. Сборщику мусора гораздо проще собрать группу объектов без перекрёстных ссылок.

        Недостатки:


        1. Чуть больше бойлерплейта, если не использовать магических десериализаторов, которые сами создадут такие геттеры.
        2. Время перехода по связям дольше, чем по прямым ссылкам.

        • bgnx
          /#18858057

          В вашем примере


          get folder() {
                  return this.store.folders[ this.folder_id]
              }

          если вы предлагаете просто вынести болерплейт аналогично такому


          class Comment {
           get folderName {
           return AppState.folders[AppState.projects[AppState.tasks[this.taskId].projectId].folderId].name
          }

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


          class Comment {
            get task(){
             return AppState.tasks[this.task_id]
           }
          }
          class Task {
             get project(){
               return AppState.projects[this.project_id]
            }
          }
           class Project {
              get folder(){
                 return AppState.folders[this.folder_id]
              }
           }

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


          <div>{comment.task.project.folder.name}</div>

          Но надо еще не забыть про прямые связи чтобы мы могли удобно рендерить списки (<div>{project.tasks.map(task=><Task task={task}/>}</div>) а не заниматься маппингом айдишников на объекты вручную


          class Task {
             get comments(){
               return this.comments.map(commentId => AppState.comments[commentId])
            }
          }
           class Project {
              get tasks(){
                 return this.tasks.map(taskId => AppState.tasks[taskId])
              }
           }
           class Folder {
             get projects {
               return this.projects.map(projectId => AppState.comments[projectId])
             }
           }

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


          class Task {
            ...
            get text(){
              return AppStore.tasks[this.id].text;
            }
            set text(newVal){
               return AppStore.tasks[this.id].text = newVal;
            }
          }

          В итоге, на мой взгляд, эта нормализация а потом попытка скрыть этот факт за сеттерами и геттерами это добавление ненужной абстракции на пустом месте. У нас уже есть возможность сохранить объект прямо на свойстве и обращаться к нему по ссылке. И это нативная возможность js без всяких слоев сверху. А мы добавили еще один слой абстракции только для того чтобы избавиться от ссылок и обращаться к объектам по айдишниками но при этом навешиваем сверху кучу геттеров все равно создав видимость обращение по ссылке.
          Да, можно возразить, что для больших приложений чтобы не было тормозов все равно придется подключить mobx и там тоже будут геттеры и сеттеры но в этом случае они необходимы и не создают отдельный логически слой абстракции так как решают чисто техническую проблему уменьшения времени обновления компонентов. Более того с mobx это нормализация будет неэффективной потому что mobx трекает только факт обращения к хешу а не смотрит на айдишник и это значит что если комментарий в рендере обращается к стору <div>{comment.task.text}</div> где свойство ".task" это геттер (AppState.tasks[this.comment_id]) то любое изменение хеша (например добавление нового комментария) вызовет перерендер всех компонентов у которых происходит обращение к хешу AppState.tasks)

          • vintage
            /#18858351

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

            Я вроде бы именно так и написал. Собственно, преимущества и недостатки я расписал. Бойлерплейт легко прячется за обобщённым кодом.


            mobx трекает только факт обращения к хешу а не смотрит на айдишник

            Я сделал прокси-реестр, который трекает каждый ключ отдельно.

        • VolCh
          /#18858101

          Чем хуже вариант:


          class Task {
            _folder: Folder
            get folder() {
              return this._folder;
            }
          }

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

          • vintage
            /#18858365

            нужность отслеживается стандартным сборщиком мусора и мы вообще о ней не думаем

            Сборщик может отследить только доступность, а не нужность. При наличии перекрёстных ссылок между всеми сущностями или общего реестра — все они всегда доступны. Но нужны обычно только когда где-либо рендерятся. ОРП позволяет отслеживать что реально где-либо используется, а что нет.


            Проще ему или нет нас особо не интересует, пока не возникнет нужда в оптимизации, которая далеко не факт, что вообще возникнет.

            А когда возникает — начинаем рвать на себе волосы. В чём проблема сразу использовать паттерны допускающие масштабирование?

        • babylon
          /#18858887

          Перекрёстные ссылки можно хранить в отдельных от объекта массивах. Объект или массив это всего лишь способ хранения и доступа к данным.

      • VolCh
        /#18857983 / -1

        MobX не предполагает денормализованых сторов, так же как и в redux его основная концепция в едином источнике правды для любой сущности. Разница между ними лишь в способе связи нормализованных данных: объектные ссылки JS против «json» ссылок по значению идентификаторов. И то, и то ссылки, просто у них разная техническая реализация.

  2. Kain_Haart
    /#18854763

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

    Можете пояснить, зачем эти ограничения?

    • k12th
      /#18854805 / +1

      Дети, воспитанные редуксом, боятся setState, потому что Дэн Абрамов заругается.


      А ограничение на дополнительные библиотеки — вероятно, просто потому что возникает ощущение, будто их и так уже слишком много?

      • Kain_Haart
        /#18854829

        Прочитав статью возникает ощущение, что получается «как не использовать стороннюю библиотеку, а написать свою»

        • k12th
          /#18854873 / +1

          Я не уверен, какова была цель автора, но статья мне показалось полезной в смысле демистификации решений управления состоянием в реакте.

      • Kain_Haart
        /#18854869 / -1

        Ваша критика использования редакс выглядит сугубо эмоциональной. Кто-то в свою очередь может сказать что «Дети воспитанные хабром боятся использовать сторонние библиотеки потому что k12th заругается»
        Такой подход не раскрывает плюсов и минусов ни вашего подхода, ни redux

        • k12th
          /#18854883

          Я просто вам подсказал, откуда боязнь библиотек и setState. И я не автор статьи.

          • Kain_Haart
            /#18854911

            Простите, действительно спутал вас с автором, но суть проблемы это не меняет

    • VolCh
      /#18857987 / +1

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

  3. Kain_Haart
    /#18854773

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

    И сделать компонент view слоя завязанным на полную схему данных model слоя? Звучит как сомнительное удовольствие

    • bgnx
      /#18855129 / -1

      А как вы предлагаете обойтись без завязки на схему данных стора? Связь в любом случае где-то будет, можно добавить селекторы и тогда обращаться не как AppState.locale или todo.name а как AppState.getLocale() или todo.getName() добавив сомнительную возможность легкого переименования полей но увеличив общую сложность добавив еще один indirection слой абстракции

      • Kain_Haart
        /#18855355

        «Где-то» будет, но хорошо бы не в коде компонентов уровня представления.


        Преимущества распределения ответственности и принципов low coupling / high cohesion далеко не ограничивается "возможностью легкого переименования", как наиболее значимые на мой субъективный взгляд я бы привел


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

        Достигается например передачей во вью контроллера в классическом MVC или созданием «контейнер-компонентов» с помощью например react-redux connect или react 16.3 context

      • Kain_Haart
        /#18855425

        Возможно я неправильно понял вашу концепцию, но меня удивило именно предложение "в любом компоненте… заимпортить и использовать данные нашего стора"

        • bgnx
          /#18855679

          В описанном в статье подходе к организации сторов заимпортить прямо можно только тот синглтон-объект AppStore который хранит обычно данные для всего приложения (кстати неважно будут ли эти данные хранится в одном синглтон-объекте или разнесены по разным объктам или даже файлам) Эти данные обычно не связаны с конкретной сущностью — различные настройки, локаль, размеры окна и.д. Многим компонентам могут потребоваться такие данные. Можно разрешить импорт только рутовому компонент App и дальше передавать другим компонентам через пропсы. Можно заюзать контекст (или например ненужные на мой взгляд инжекты mobx-а) и тогда главный компонент может передать через провайдер и тогда не нужно прокидывать пропсы через промежуточные компоненты. Но самым простым способом будет просто сделать импорт нужного объекта и использовать в нужном компоненте.

          • gnaeus
            /#18856477 / +1

            Можно заюзать контекст (или например ненужные на мой взгляд инжекты mobx-а)

            @inject из mobx-react это и есть HOC для контекста. Просто удобнее писать:


            @inject('projectStore')
            @inject('todoStore')
            class MyComponent

            Чем:


            <ProjectContext.Consumer>
              {projectStore => (
                <TodoContext.Consumer>
                  {todoStore => (
                    <MyComponent projectStore={projectStore} todoStore={todoStore} />
                  )}
                </TodoContext.Consumer>
              )}
            </ProjectContext.Consumer>

    • VolCh
      /#18857967

      Так или иначе она будет завязана, если считать любое отображение данных модели на вью частью вью, будь это специально созданные для вью селекторы или маппинг селекторов «общего назначения» на свойства в connect().

      При описанном подходе никто не мешает вам создать свой аналог редаксовского connect(), возвращающий HOC, который маппит данные импортированного стора на свойства целевого компонента.

  4. joniks
    /#18855105

    Костыль

    • bgnx
      /#18855111

      А какой способ работы с состоянием, по-вашему, не является костылем?

      • PapaBone_q
        /#18858199

        React.createContext

        • Druu
          /#18859449 / +1

          Всего-то 5 лет понадобилось, и вот, костыли из первого ангуляра теперь называют "некостылями" в реакте.

  5. killaw
    /#18858195

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

    А что за метод update у Object?

    • bgnx
      /#18858197

      Я опечатался) — там Object.assign конечно же