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

    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()) на конкретное поле конкретного объекта состояния.

    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 57
      0

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

        0

        Для того чтобы обновить сущности когда приходит ответ от сервера (также как и при получении обновлений по вебсокетам) когда данные приходят в нормализированном виде — хеше объектов где айдишнику соответствует объект с данными то нужно по айдишнику обновить нужный объект в нашем вложенном дереве объектов связанных ссылками. В статье этот способ не описан в деталях я лишь упомянул что нужно добавить учет каждого созданного объекта в глобальной хеш-мапе. То есть нужно просто в конструкторе базового класса сгенерировать айдишник для нового созданного объекта и закешировать его в глобальном словаре.
        И теперь при получении данных от сервера всегда можно вытащить нужный объект по его айдишнику и обновить нужные в нем данные а сама структура всех данных в состоянии остается в древовидном виде или точнее в виде графа (если учитывать обратные ссылки на родительские объекты).
        То есть нормализация остается только для нужд общения с сервером а для компонент и всего остального данные у нас удобно вложены и ссылаются друг на друга по ссылкам. Это упрощает использование данных как и в шаблонах компонентах так и в обработчиках в отличии от организации данных изначально в нормализованном виде в плоском хеше таблиц как это принято делать используя 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)) что может сказаться на обработке большого количества данных

          0

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

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

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

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


            1. состояние прибито к виду — стор должен знать о том, какую вложенность объектов требуют виды, чтобы именно с этой вложенностью объекты и хранились
            2. избыточность и дублирование данных — при хоть сколько-нибудь сложных связях между объектами у вас один и тот же объект будет вложен в много разных других объектов. своевременно все это обновлять и следить за корректностью очень быстро становится сильно сложнее, чем денормализовывать данные на выходе стора.
              0
              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) это изменение одного и того же объекта и соотвественно никакого дублирования и неконсистентности не будет

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

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


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

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

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

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

                  Вы хотите сказать что когда загружаем из сервера список задач то получаем новые объекты а вот ссылки из разных мест графа останутся старыми? Я в статье и в комментарии выше упомянул что при получении обновленных объектов от сервера нужно вытащить из хешмапы по его айдишнику тот объект который находится в графе и обновить его свойства не создавая новый объект
                    0
                    Если появится фича коллаборации — например расшарить папку другим пользователем то появляется просто many-to-many связь и у юзера будет массив ссылок на папки а у папки будет массив ссылок на других объектов-юзеров.

                    Ну вот, о чем и речь. У вас изменился вид — и пришлось менять стор.


                    Как при таком подходе стор может быть прибит к виду?

                    Именно так, как вы описываете. Поменялась немного логика вида = переписываем стор.


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

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

                      +3
                      Ну вот, о чем и речь. У вас изменился вид — и пришлось менять стор.

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

                      Я выше упомянул что того чтобы обновить объект который находится глубоко в дереве-графе нужно будет вытащить этот объект по его айдишнику из отдельного хеша и обновить его свойства. То есть помимо того что объект находится глубоко в дерево на него еще будет ссылка по айдишнику из хеша. И при создании объекта нужно еще и записывать его айдишник и ссылку на него в этом хеше и удобней будет это сделать в конструкторе базового класса. Получается что с одной стороны у нас сущности хранятся в виде графа объектов связанный ссылками и эти объекты удобно передавать по ссылке компонентам (без маппинга айдишников на объекты) и удобно обращаться к связанным сущностям из компонентов по ссылке а не вытаскивать каждый раз объект по его айдишнику из хеша. А с другой стороны для нужд общения с сервером, обновления данных и прочего эти же самые объекты (точнее ссылки на них) заодно хранятся в отдельном хеше где айдишнику будет соответствовать нужный объект и теперь удобно обратиться по объекту зная его айдишник когда это потребуется.
                        0
                        Этот хэш очень похож на Identy Map :) Вот только не согласен, что запись должна быть в конструкторе. В целом считаю, что в рамках бизнес-логики конструктор должен вызываться только один раз — при создании.регистрации новой бизнес-сущности. В некоторых языках это сложно реализовать без тормозных рефлексий или подобных хаков, но в JS это делается легко, если не пытаться с ним бороться, эмулируя, например, приватные свойства через замыкания.
                          0

                          Это Вы прямо один-в-один mobx-state-tree описываете :-)
                          Только там еще снапшоты состояния, и лог изменений в формате JSON Patch с возможностью rollback.

                            0
                            Я выше упомянул что того чтобы обновить объект который находится глубоко в дереве-графе нужно будет вытащить этот объект по его айдишнику из отдельного хеша и обновить его свойства. То есть помимо того что объект находится глубоко в дерево на него еще будет ссылка по айдишнику из хеша.

                            То есть вы обновляете объект сперва в хеше, а потом проходите по графу и обновляете все ссылки. Вам действительно кажется это проще и удобнее, чем сделать только первое действие (обновить объект в хеше и все)?


                            Тут меняется не сколько вид сколько бизнес-логика (схема сущностей и связей). Как тут можно обойтись без изменения стора?

                            Так бизнес-логика не менялась и бд не менялась. Как были две таблицы со связью многие-ко-многим, так они же и есть.


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

                            В вашем случае — придется, если же состояние было нормализовано, то нет. Просто добавите новую ф-ю для того, чтобы доставать список юзеров по папке.

                              +1
                              То есть вы обновляете объект сперва в хеше, а потом проходите по графу и обновляете все ссылки. Вам действительно кажется это проще и удобнее, чем сделать только первое действие (обновить объект в хеше и все)?

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


                              Так бизнес-логика не менялась и бд не менялась. Как были две таблицы со связью многие-ко-многим, так они же и есть.

                              Нет, была связь один-ко-многим, а стала многие-ко-многим. Если бы сразу была связь многие-ко-многим — то и менять бы ничего не пришлось.

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

                                Просто поиск делается тривиальной ф-ей (использование которой по факту не отличается от доступа через поле объекта), и не зависит от сложности связей никак. А обновление графа — вобщем-то задача в общем случае достаточно нетривиальная, и ее сложность растет со сложностью графа.
                                В случае описанной структуры (тривиальное дерево) все, конечно, ок, и если есть гарантии, что сложнее связи в бд не станут — вариант годен. но если нет — тут уж, мне кажется, нет.


                                Если бы сразу была связь многие-ко-многим — то и менять бы ничего не пришлось.

                                Почему бы не пришлось? Была связь многие-ко-многим, при этом в сторе у юзеров есть список папок, но у папки нету списка юзеров (т.к. не нужно это в данном сервисе). Потом оказалось, что нужна фича, предполагающая получение списка юзеров для данной папки. Или вы предлагаете уже стор прибить гвоздями к структуре бд? Тоже ведь ничего хорошего.

                                  0
                                  Причём вообще схема БД, если мы говорим о фронте? Да и односторонняя связь многие-ко-многим и двусторонняя — это значительные изменения бизнес-логики.
                                    0
                                    Причём вообще схема БД, если мы говорим о фронте?

                                    Это вопрос не ко мне, я как раз за то, чтобы схема БД была ни при чем.


                                    Да и односторонняя связь многие-ко-многим и двусторонняя — это значительные изменения бизнес-логики.

                                    Если представлять стор в виде графа — да, если в нормализованном виде — то нет.

                                      0
                                      Нормализованный вид или нет — дело десятое, и оно не имеет отношение к представлению стора. На то оно и представление, что можно представлять как угодно (в рамках имеющихся сущностей и связей, конечно), например, в представлении можно делать «FULL SCAN» по массиву для поиска сущности с нужным ид, а не использовать «HASH MAP».
                                        0
                                        > Нормализованный вид или нет — дело десятое, и оно не имеет отношение к представлению стора. На то оно и представление, что можно представлять как угодно (в рамках имеющихся сущностей и связей, конечно)

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

                                Не нужно делать никакого поиска по графу.Когда вытаскиваем объект по айдишнику из хеша то не заменяем его новым объектом а обновляем его свойства. И поскольку все ссылки в графе ссылаются на этот объект который мы достали из хеша они как бы автоматически получат (потому что это один и тот же объект) обновленные значения свойств
                                  0
                                  > Не нужно делать никакого поиска по графу.Когда вытаскиваем объект по айдишнику из хеша то не заменяем его новым объектом а обновляем его свойства.

                                  Я неточно выразился, подразумевалась загрузка нового объекта (например, новой папки для юзера), а не обновление свойств старой. С-но, вам надо пробежаться по графу и везде расставить на этот объект ссылки (и обратные с объекта, если надо). Аналогично в случае удаления.
                                    0
                                    А зачем загружать новый объект если можно обновить свойства старого объекта и избежать прохода по графу с заменой?
                                      0
                                      А зачем загружать новый объект если можно обновить свойства старого объекта и избежать прохода по графу с заменой?

                                      За тем, что старого объекта еще нет. Не был загружен. Или не был создан, когда загружали объекты прошлый раз.

                    0

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


                    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. Время перехода по связям дольше, чем по прямым ссылкам.
                      0

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


                      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)

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

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


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

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

                        0

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


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

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

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

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


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

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

                          0

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

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

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

                          +5

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


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

                            0

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

                              +3

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

                            –1

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

                              0

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

                                0

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

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

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

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

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


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


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

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

                                  0

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

                                    0
                                    В описанном в статье подходе к организации сторов заимпортить прямо можно только тот синглтон-объект AppStore который хранит обычно данные для всего приложения (кстати неважно будут ли эти данные хранится в одном синглтон-объекте или разнесены по разным объктам или даже файлам) Эти данные обычно не связаны с конкретной сущностью — различные настройки, локаль, размеры окна и.д. Многим компонентам могут потребоваться такие данные. Можно разрешить импорт только рутовому компонент App и дальше передавать другим компонентам через пропсы. Можно заюзать контекст (или например ненужные на мой взгляд инжекты mobx-а) и тогда главный компонент может передать через провайдер и тогда не нужно прокидывать пропсы через промежуточные компоненты. Но самым простым способом будет просто сделать импорт нужного объекта и использовать в нужном компоненте.
                                      +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>
                                  0
                                  Так или иначе она будет завязана, если считать любое отображение данных модели на вью частью вью, будь это специально созданные для вью селекторы или маппинг селекторов «общего назначения» на свойства в connect().

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

                                  Костыль

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

                                      React.createContext

                                        +1

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

                                          +1

                                          А как вы предлагаете с помощью React.createContext управлять достаточно сложным состоянием (хотя бы массивом)?


                                          Например, чтобы Context.Consumer перерендерил свои внутренние компоненты при добавлении элемента в массив, этот массив должен быть либо immutable (что подводит нас к Redux), либо реактивным (в терминах MobX).

                                            0
                                            этот массив должен быть либо immutable

                                            Да, react context про immutable значения. ===

                                              0

                                              React.createContext дает нам аналоги <Provider> и connect из react-redux.
                                              Но не дает аналоги dispatch, action и reducer из самого Redux.
                                              Так же, как не дает аналоги @observable и @observer из MobX.


                                              Таким образом, он отвечает на вопрос, как передавать состояние вниз по дереву. Но оставляет открытым вопрос, как управлять этим общим состоянием извне компонента, в котором находится <Context.Consumer>.


                                              А еще за бортом остаются такие вещи, как Dev Tools, Time Travel, Single Source of Truth, Middleware, etc.


                                              Вместе с тем, стандартизация React Context API — огромный шаг вперед. Теперь мы можем не пихать Redux в небольшие приложения только чтобы "расшарить" тривиальное состояние.

                                                0

                                                Согласен, для больших приложений это едва ли годится. Конечно можно все необходимые методы вниз по древу тем же context-ом, но удобным это едва ли можно назвать.

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

                                      А что за метод update у Object?
                                        0
                                        Я опечатался) — там Object.assign конечно же

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое