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

MobX – менеджер состояния, к этому времени 6 версии, которая работает благодаря Proxy. Далее мнение основано на использовании MobX v6 в связке с библиотекой React при разработке мобильных (React Native) и веб-приложений. Стоит уточнить, что я пользовался в прежних проектах MobX v4, react-easy-state, Redux, Zustand, а также ознакомлен с десятком альтернативных менеджеров состояния на уровне чтения их документации. Так же замечу, что все приведенные далее плюсы и минусы не полны и выведены в сравнении с другими менеджерами состояния.

Плюсы

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

import { makeAutoObservable } from 'mobx';
export const chatStore = makeAutoObservable({
  chats: {},
  messages: {},
  createChat: () => {},
  createMessage: () => {},
});

Очень естественная работа с хранилищами как с объектами. Отсюда вытекают подсказки типов, обращения к полям, автоимпорт и прочие плюшки – Proxy творят чудеса.
Пример:

import { chatStore } from 'stores';
const Chat = () => {
  const messages = chatStore.messages;
  const onPress = chatStore.createMessage;
  return null;
};

Лёгкое изменение состояния. Да, я в целом за имутабельность, но именно здесь я не вижу в ней смысла, так как при необходимости я могу сам создавать полные снимки состояния всех хранилищ, поместив их в один объект и вызывая JSON.stringify или что-то кастомное, если потребуется. В Redux проблема решается подключением immer для глубоко вложенных объектов. И да, все зависит от того, насколько точечным является изменение объекта, и там где можно воспользоваться средствами функционального программирования, ими же и пользуемся.
Пример:

import { makeAutoObservable } from 'mobx';
export const chatStore = makeAutoObservable({  
  messages: {},
  createMessage: (message) => {
    chatStore.messages[message.id] = message;
  },
});

Селекторы. Здесь MobX действительно блистает. Когда необходимы срезы данных лишь на основе хранилищ, используем геттеры, в остальных случаях храним параметризованные селекторы в отдельных файлах с применением computed от MobX computedFn из mobx-utils. При этом все они автоматически мемоизируются MobX, что позволяет надеяться на хорошую производительность приложений. Не то, чтобы в Redux были сложности с reselect, но здесь опять же код и пишется, и читается проще.
Пример:

import { makeAutoObservable } from 'mobx';
import { profileStore } from 'stores/profileStore';
export const chatStore = makeAutoObservable({
  messages: {},
  get myMessages() {
    return Object.values(messages).filter((message) => message.userId === profileStore.userId);  
  }
});

Минусы

Оборачивание компонентов в observer HOC для добавления им реактивности. Обходится путем написания сниппетов для компонентов и привыканием, как и у любых, основанных не на хуках-селекторах, менеджеров состояния. Плюсом добавляет мемоизацию компонентов, а так как у нас принято мемоизировать любой компонент, то фактически для нас это лишь замена одного вызова на другой.
Пример:

import { observer } from 'mobx-react-lite';
const Chat = () => null;
export default observer(Chat);

Сбои компонентов при обновлении их кода с хуками на ходу. Конкретнее - добавление или удаление хука с последующим сохранением приводит к сбоям. Дело в плохой работе Fast Refresh с HOC вокруг экспортируемого дефолтного компонента, а не в самом MobX. Чтобы не менять привычную структуру проекта, в которой мы экспортируем компоненты по умолчанию через export default observer(Component), пришлось изучить написание babel-плагинов. Был создан плагин, который переносит вызов observer в объявление функции компонента и убирает вызов из экспорта. Стало хорошо. Конечно, вы скажете, зачем такие заморочки, ведь по докам MobX требуется завернуть компонент именно при объявлении. Отвечу, что при использовании HOC'ов в экспорте код смотрится гораздо красивее и имеет меньшую вложенность. Плюс, смена memo на observer делается проще, там где требуется использовать хранилища MobX.
Пример:

import { observer } from 'mobx-react-lite';
const Chat = () => {
  // удаление или добавление хука приведет к сбою fast refresh
  const [visible, setVisible] = useState(false);
  return null;
};
export default observer(Chat);

Необходимость соблюдать осторожность при работе с MobX-объектами внутри компонента. Так как мы работаем с реактивными мутабельными данными, то надеяться на их неизменность при передаче куда-то ещё, в том числе, внутрь других объектов, уже нельзя, в отличие от данных Redux. Например, если мы захотим хранить ту же историю изменений в каком-нибудь редакторе. В таких случаях необходимо помнить о MobX-костыле под названием toJS, который преобразует данные в обычные объекты Javascript.
Пример:

import { chatStore } from 'stores';
import { useRef } from 'react';
import { toJS} from 'mobx';
const Chat = () => {
  const messages = chatStore.messages;
  const prevMessages = useRef();
  const onPress = () => {
    const messagesPurified = toJS(messages);
    prevMessages.current = messagesPurified;
    // не вызвать перед этим toJS = выстрелить себе в ногу
  };
  return null;
};

Отсутствие хороших инструментов, аналогичных Redux DevTools. Благо у меня были наработки для react-easy-state, что позволило их дополнить и создать библиотеку для работы MobX с Redux DevTools. Вкратце, в ней я оборачиваю и заменяю все действия MobX на логирующие функции, и создаю снимки состояния хранилищ при из вызове. Мониторить изменения MobX-хранилищ стало легко и приятно.

И конечно не могу не упомянуть, как неудобна отладка Proxy-объектов, ведь именно на них построен MobX 6. Их всегда нужно открывать, чтобы кликнуть по полю target, где и лежит нужный нам объект. Когда выводим логи, то ещё можно обойтись оборачиванием в toJS от MobX, а вот при отладке ещё не придумал решение. Возможно, есть настройка отображения Proxy в браузере и Visual Studio Code, пока что это мне не ведомо.
Пример:

p = new Proxy({}, {})
p
>  Proxy {}
      [[Handler]]: Object
      [[Target]]: Object
      [[IsRevoked]]: false

Итог

MobX — достойный менеджер состояния, хоть и потребовавший доработки под нужды нашего проекта. Несмотря на небольшие проблемы при отладке Proxy-объектов, простота написания, отличная читаемость кода, а так же хорошая производительность благодаря мемоизации геттеров и computed computedFn от mobx-utils на мой взгляд делают его одним из лучших решений.

Обновление статьи: поправил форматирование кода, исправил грубую ошибку насчет мемоизации computed.

Комментарии (24)


  1. MaZaAa
    29.07.2021 12:11
    +3

    С приходом 6 версии перестали быть нужны классы и декораторы. И то, и другое я считаю ненужным и даже вредным синтаксическим сахаром

    Классы и декораторы никогда не были в MobX'e обязательным условием, все точно так же можно было использовать через observable({ test: 1 }) и т.п.

    Но как бы с классами и декораторами намного удобнее, приятнее и читаемее.

    Оборачивание компонентов в observer HOC для добавления им реактивности

    Как бы решается просто и надежно:
    import * as React from 'react';
    import { observer } from 'mobx-react';
    import { configure } from 'mobx';
    
    const wrappedComponentsSet = new WeakSet();
    const wrappedComponentsMap = new WeakMap();
    
    // @ts-ignore
    const createElement = React.createElement;
    // @ts-ignore
    React.createElement = function() {
        const target = arguments[0];
    
        if (typeof target === 'function' && !wrappedComponentsSet.has(target)) {
            if (target.prototype.shouldComponentUpdate) {
                delete target.prototype.shouldComponentUpdate;
            }
    
            wrappedComponentsSet.add(target);
            const wrapped = observer(target);
            arguments[0] = wrapped;
            wrappedComponentsMap.set(target, wrapped);
        } else if (wrappedComponentsSet.has(target)) {
            arguments[0] = wrappedComponentsMap.get(target) || observer(target);
        }
        return createElement.apply(this, arguments)
    };
    


    И теперь просто пишем
    export function MyComponent() {
        return <div>{userState.email}</div>;
    }
    

    И все работает и все реактивное.

    MobX — достойный менеджер состояния

    Это единственное, безальтернативное и лучшее из доступных на сегодня решений, чтобы можно было работать с React'ом без боли и страданий.


    1. nin-jin
      30.07.2021 10:51
      +2

      Зачем манкипатчить реакт, потенциально ломая сторонние компоненты, если можно зарегистрировать кастомную jsx фабрику?


      1. markelov69
        30.07.2021 11:33

        Зачем манкипатчить реакт, потенциально ломая сторонние компоненты, если можно зарегистрировать кастомную jsx фабрику?

        Если сторонний компонент сломается от HOC'a observer, то это похоже кривой компонент и я бы задумался о его использовании)

        Я тоже патчил React.createElemnt схожим образом для того, чтобы автоматом все компоненты были observerver'ами, заодно автоматом заворачивал компоненты в ErrorBoundary чтобы вся страница не падала, а падал только конкретный компонент в котором возникла ошибка, и он показывал текст «Something went wrong».
        Проблем не было ни разу с этим. в том числе со сторонним ant-design.

        Пишешь чистый код и не думаешь об этих обертках, просто всё работает.


        1. nin-jin
          30.07.2021 12:14

          Вы может быть ещё и ремнём безопасности в автомобиле не пользуетесь, ибо "всегда прокатывало"?


          1. markelov69
            30.07.2021 12:29

            Вы так говорите, как будто это какая-то проблема. Лично у меня 2 года работает эта штука в разных проектах и чувствует себя великолепно. Если вдруг произойдет немыслимое и оборачивание стороннего компонента в observer его сломает, и вот нужно именно его использовать, то можно зарегистрировать JSX фабрику кастомную и там это провернуть или добавить условие на проверку компонента по ссылке, и если его не надо заворачивать, то просто не заворачиваем, какие проблемы то?

            Вы развели несуществующую «проблему» на ровном месте, смысл?
            Я предпочитаю хороший и чистый код. Меня не волнует шанс 1 из миллиона, что что-то может пойти не так и ради этого шанса писать код более грязным.

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


            1. nin-jin
              30.07.2021 14:44

              можно зарегистрировать JSX фабрику кастомную

              Так и в чём проблема сразу сделать хорошо, а не говнокодить?


              1. markelov69
                30.07.2021 15:37

                Так и в чём проблема сразу сделать хорошо, а не говнокодить?

                Если нет разницы, то зачем платить больше?


                1. nin-jin
                  30.07.2021 16:12
                  -1

                  Вообще-то меньше. Завязываем с демагогией.


    1. 1212evgen
      30.07.2021 20:46
      +2

      Пару замечаний о mobx. Согласен, что mobx это крутое решение для работы с состоянием. У меня мало опыта с разными менеджерами состояний, пробывал redux, и немного overmid, но как только ознакомился с документацией, я понял что работать с состоянием я буду только с mobx.

      Единственное, что тяжело мне было понять, как организовать архитектуру проекта, потому что мало информации по этому поводу, либо информация сильно устаревшая. Крупных проектов в открытом доступе которые используют mobx мало, возможно я плохо искал. И в этом есть проблема в изучении и понимании принципов mobx, потому что многие используют mobx неправильно, разработчики не понимают когда использовать computed, когда использовать autorun, а когда reaction. Примеры использования в более серьезных проектах при изучении mobx найти тяжело.


      1. Alexandroppolus
        31.07.2021 12:44

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

        Использовать бестпрактисы ООП. Паттерны, SOLID, DI, GRASP, вот это вот всё. Ничего придумывать не надо.

        разработчики не понимают когда использовать computed, когда использовать autorun, а когда reaction.

        Наверно, те разработчики просто не читали документацию.


  1. kubk
    29.07.2021 12:26
    +5

    Откуда это слепое убеждение, что иммутабельность лучше мутабельности? В любом мало-мальски известном и навороченном движке для разработки игр используются махровое ООП с классами и прямые мутации стейта. Только веб-разработчики почему-то решили, что двигать кнопки и клепать формы на 3 поля нельзя с мутабельным подходом, нужен обязательно иммутабельный.

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

    Если вы хотите хранить историю изменений, то тут гораздо лучше подходит Immer от автора Mobx (так как Immer считает diff'ы между стейтами), а не Redux, который будет запоминать N-последних слепков всего стейта. В документации к Redux пишут, что с ним легко делать undo, но на практике это будет отъедать всю память, об этом в том числе писал и сам Абрамов: https://twitter.com/dan_abramov/status/1295823989542211590


    1. Vlad_Murashchenko
      29.07.2021 17:05
      +2

      Один нюанс, слепки памяти не являются полными при иммутабельности. Общие объекты между изменениями переиспользуются по ссылкам, это называется structural sharing. Так что это сводится к тому же хранению разницы. Возможно иммутабельный подход будет кушать даже меньше памяти при undo/redo, так как все эти proxy и observers на каждое поле тоже едят не мало


    1. JustDont
      29.07.2021 17:07

      Для истории изменений всё так же остаётся концепт трансформеров (теперь уехали в mobx-utils, раньше это лежало в самом mobx), благодаря которому каждый кусок мутабельного стейта mobx можно превращать в иммутабельный, и куда-то складывать. И там уже что душа желает — хоть слепки делать, хоть диффы, хоть еще что угодно.


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


    1. Alexandroppolus
      29.07.2021 17:24

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

      Ещё тут надо отметить, что мобиксовый observer не всесилен. Например, есть такой FlatList из react-native. Он получает в пропсы массив и рендерит его со скроллом и прочими плюшками. Внутри себя FlatList просто рендерит VirtualizedList (с передачей массива ему), а тот уже делает цикл по массиву. Навешивать observer на FlatList бесполезно, мутабельные изменения самого массива (например, push) пойманы не будут. Вот он, очень простой кейс, когда вместо push приходится иммутабельно arr = [...arr, newItem]


      1. JustDont
        29.07.2021 17:28

        А в чем тут проблема обернуть FlatList в HOC, в котором запустить reaction() и пропихивать в FlatList новый инстанс массива при каждом его изменении? С помощью reaction всегда можно "выйти" из реактивности mobx в суровый внешний мир и отдавать этому внешнему миру изменения именно в том виде, в котором он желает.


  1. Alexandroppolus
    29.07.2021 17:06
    +1

    Отступы! Где чертовы отступы??

    При этом все они автоматически мемоизируются MobX, что позволяет надеяться на хорошую производительность приложений.

    Тут надо помнить, что мемоизация живет, пока есть хотя бы один наблюдатель за компутедом (или указана специальная опция). Если нет, то перевычисляется каждый раз. Так что если это надо именно для производительности, то возможно придется подстраховать через memoize/memoizeOne/etc


    1. JustDont
      29.07.2021 17:13

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

      Если за компутедом никто не наблюдает, то по умолчанию он вообще не вычисляется.


      1. Alexandroppolus
        29.07.2021 17:26
        +1

        Будет вычисляться при каждом вызове из нереактивного кода. Это я к тому, что computed не на 100% замена memoize.


  1. JustDont
    29.07.2021 17:20

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

    Для этого вам всегда нужно помнить, что же конкретно делает makeAutoObservable. А делает она очень многое, и если в простых случаях там всё легко, то в сложных — сюрпризы очень возможны.
    А если вы будете брать makeObservable, то там всегда будет дублирование деклараций (сначала указать поле, а потом еще объяснить mobx, что поле "blahblah" надо оформить как обсервабл).
    И только с декораторами всё всегда читается совершенно однозначно и без дублирования. При этом я отлично понимаю, почему в доке mobx появился тезис "мы не рекомендуем использовать декораторы" (потому что со стандартом декораторов происходит тот еще цирк).


    1. DmitryKazakov8
      29.07.2021 20:38
      +2

      Декорирование — древний концепт, и нападок на него как на паттерн я не понимаю. Это оборачивание функции в новую функцию, которая делает что-то дополнительное, а затем вызывает оригинал [+ что-то дополнительное]. Если нападки на синтаксис с "собачкой" над свойствами класса — так это можно воспринимать как сахар, который стабильно корректно транспайлится бабелем уже много лет и не вызывает проблем.


      Пожалуй, отказ от этого сахара может быть только по одной более-менее разумной причине — желании сохранять итоговый код как можно ближе по синтаксису к исходному. Но тогда исходный надо писать на ES2015 — на Хабре есть такие любители, даже если профессионалы, но все равно встречаются нечасто. А тут вот в статье еще один — никаких классов (для сторов и для компонентов), никаких типов, никакого Prettier с форматированием с отступами. Удивительно, что Proxy со скрипом, но приняты к использованию...


      1. VasilGor Автор
        29.07.2021 21:54

        никаких типов

        Только в примерах, так то я активно работаю с JSDoc и/или Typescript.

        никакого Prettier с форматированием с отступами

        Извините, форматирование есть в исходнике статьи, просто пока не разобрался почему отображается она без них. К Prettier и Eslint я давно привык, и не представляю как без них можно работать.


  1. Rikkit
    29.07.2021 21:07
    +2

    С приходом 6 версии перестали быть нужны классы и декораторы. И то, и другое я считаю ненужным и даже вредным синтаксическим сахаром

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

    Да, я в целом за имутабельность

    Я целый год писал на иммутабельном redux и вообще не понимаю в чем прикол. Что дает иммутабельность в реальном frontend проекте? Я чаще замечаю результат дает правильная архитектура.

    Для референса, моя архитектура имеет схожести с решением в данной статье: https://blog.logrocket.com/using-mobx-for-large-scale-enterprise-state-management/


    1. VasilGor Автор
      29.07.2021 21:46

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

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

      Я целый год писал на иммутабельном redux и вообще не понимаю в чем прикол. Что дает иммутабельность в реальном frontend проекте?

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


      1. Codenamed
        30.07.2021 00:51

        JavaScript-классы не просто полезны, а необходимы для того, чтобы обеспечить какое-то приближение к изоморфизму с серверным кодом на основных объектно-ориенторованных языках. И борьбы с тошнотой и триггерфингерным рефлексом у серверных разработчиков, которые вынуждены заниматься фронтом :)