MobX или приправа реактивности для JS

от автора

Привет, Хабр!

Меня зовут Сергей Волков, я фронтенд-разработчик в компании VK. Мы используем MobX для работы с реактивными значениями в веб-приложениях, поэтому я хочу познакомить вас с этим инструментом и показать, почему на него стоит обратить внимание.

В этой статье я хочу поделиться своими мыслями о MobX — инструменте, который я искренне полюбил после многих лет разработки интерфейсов. Приятного чтения! 🙂

Да кто такой этот ваш MobX?

древний мем

древний мем

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

Лично я вижу MobX как своеобразный «пластилин» для архитектуры.Да, инструмент диктует определенные правила, но они касаются лишь базовой работы с реактивностью: вы объявляете данные как observable/computed, а система сама отслеживает их использование и точечно обновляет интерфейс при любых изменениях. Во всём остальном у вас полная свобода.В отличие от других инструментов, здесь нет жесткой привязки к редюсерам, обязательной иммутабельности или строгой необходимости прокидывать каждое изменение через диспатчи.Вы можете использовать (а можете не использовать) привычные классы, применять паттерны ООП, инкапсулировать логику прямо рядом со стейтом и строить архитектуру так, как удобно именно вам, избавляясь от тонн бойлерплейта.

Сегодня в мире React разработки у нас есть огромный выбор инструментов для управления состоянием. У каждого из них свои преимущества, недостатки и неизбежный шаблонный код (бойлерплейт), без которого пока никуда. Вот лишь малая часть популярных альтернатив, с которыми часто сравнивают MobX:

  • Jotai

  • Zustand

  • Redux

  • $mol

  • Effector

  • Reatom

  • Nanostores

  • kr-observable

  • Recoil

  • XState — иногда дополняет сам MobX

  • Backbone

Это отнюдь не полный список, а лишь малая часть альтернатив.

История знакомства

За свою карьеру я поработал над множеством проектов, но один из них удивил меня особенно сильно. (Tibbo привет!)

Бизнес-задача заключалась в разработке большого и высоконагруженного веб-приложения. По сути, это был сложный конструктор: low-code инженеры самостоятельно собирали интерфейсы прямо в дашборде. Пользователь сам определял количество тяжелых компонентов на экране, сам задавал их реактивные свойства, и всё это в реальном времени синхронизировалось с сервером. И это лишь малая часть того, на что была способна система.
Именно эта амбициозная техническая задача и её очень аккуратная реализация на MobX заставили меня задуматься: а как бы это вообще выглядело на том же Redux или Zustand?
Скажу честно: сделать это было бы реально. Но реализация оказалась бы на порядок сложнее, многословнее и, скорее всего, обросла бы костылями.

За что я его полюбил ?

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

1. Минимальный шаблонный код

Что нужно, чтобы изменить реактивное значение? Правильно — использовать стандартные механизмы языка.

const store = makeAutoObservable({  calls: 0,  fruits: [] as string[],  get count() {    return this.fruits.length;  }});// UIconst addFruit = () => {  store.calls++;  store.fruits.push("apple");};

А как это выглядит в классическом Redux (даже с современным RTK)? Ну, тут надо создать слайс, написать редюсеры, экспортировать экшены, а потом в месте вызова не забыть про хук useDispatch. Кажется, вот так:

import { createSlice, PayloadAction } from "@reduxjs/toolkit";import { RootState } from "./store";const storeSlice = createSlice({  name: "store",  initialState: { calls: 0, fruits: [] as string[] },  reducers: {    addFruit: (state, action: PayloadAction<string>) => {      state.calls += 1;      state.fruits.push(action.payload);    },  },});export const { addFruit } = storeSlice.actions;export const selectCount = (state: RootState) => state.store.fruits.length;// UIconst dispatch = useDispatch();const handleAction = () => {  dispatch(addFruit("apple"));};

Разница в объеме кода, количестве абстракций и ментальной нагрузке ради одного добавления элемента в массив, вычисляемого значения и инкремента счётчика, думаю, очевидна. Ладно, вы мне, наверное, скажете: «редакс это уже не модно, к тому же сам Ден Абрамов от него отказался, ты лучше покажи, как это будет выглядеть на модном зустанд!» Окей, давайте попробуем написать:

import { create } from "zustand";interface StoreState {  calls: number;  fruits: string[];  addFruit: (fruit: string) => void;}const useStore = create<StoreState>((set) => ({  calls: 0,  fruits: [],  addFruit: (fruit) =>    set((state) => ({      calls: state.calls + 1,      fruits: [...state.fruits, fruit],    })),}));const selectCount = (state: StoreState) => state.fruits.length;// UIconst addFruit = useStore((state) => state.addFruit);const handleAction = () => {  addFruit("apple");};

Даже в Zustand, который славится своей лаконичностью, нам приходится вручную контролировать иммутабельность массива через спред ([...state.fruits, fruit]) и писать селекторы в компонентах, чтобы вытащить длину массива, не ломая оптимизацию рендеринга. В MobX всё это происходит под капотом благодаря «умному» отслеживанию с помощью Proxy.

Давайте еще взглянем на аналогичный пример, но уже на Effector (без скоупов):

import { createStore, createEvent } from "effector";import { useUnit } from "effector-react";const addFruitEvent = createEvent<string>();const $calls = createStore(0)  .on(addFruitEvent, (state) => state + 1);const $fruits = createStore<string[]>([])  .on(addFruitEvent, (state, fruit) => [...state, fruit]);const $count = $fruits.map((state) => state.length);// UIconst [calls, fruits, count, addFruit] = useUnit([$calls, $fruits, $count, addFruitEvent]);const handleAction = () => {  addFruit("apple");};

Что мы получаем в итоге ? Ради инкремента, добавления строки в массив и вывода его длины нам пришлось завести два стора, событие, вручную связать их через методы .on(), породить производный стор через .map() и затем массивом прокинуть всё это в хук useUnit.

2. Прямая мутация данных

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

export const state = makeAutoObservable({  foo: { bar: { baz: 1 } },});// Просто берем и присваиваемstate.foo.bar.baz = 100;

Сложно? Пожалуй. Куда «легче» написать классическое иммутабельное обновление дерева:

// Типичная боль неизменяемых структурset((state) => ({  foo: {    ...state.foo,    bar: {      ...state.foo.bar,      baz: 100,    },  },}));

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

3. Гибкость и архитектурная свобода

MobX не заставляет пихать всё состояние приложения в единое монолитное дерево. Инструмент подстраивается под вас, а не вы под него. Нужны глобальные доменные сторы? Пожалуйста. Хотите изолировать логику в локальных View-моделях под конкретный сложный компонент? Легко. Вы просто инкапсулируете данные, computed-свойства и методы-экшены в аккуратные классы так, как этого требует ваша архитектура, а не ограничения/требования стейт-менеджера.

4. Сайд-эффекты без боли

В React-мире мы привыкли всё решать через useEffect. И все мы знаем, во что это превращается: бесконечные массивы зависимостей, лишние рендеры, старые замыкания и вот это всё.

Вместо того чтобы привязывать бизнес-логику к жизненному циклу компонента, MobX предлагает реагировать на изменения данных напрямую с помощью трёх потрясающих утилит: autorun, reaction и when.

Продолжим наш пример:

import { makeAutoObservable, autorun, reaction, when } from "mobx";const store = makeAutoObservable({  count: 0,  fruits: [],  isReadyToEat: false,});// 1. autorun: сам найдет все зависимости внутри функции и вызовется при их изменении.// Идеально для логов или синхронизации с localStorage.autorun(() => {  console.log(    `Счетчик: ${store.count}, Фруктов в корзине: ${store.fruits.length}`  );});// 2. reaction: аналог useEffect, но без проблем с массивом зависимостей.// Первым аргументом возвращаем то, за чем следим. Вторым — что делаем при изменении.reaction(  () => store.fruits.length,  (length) => {    if (length > 5) {      console.log("Ого, у нас уже больше пяти фруктов! Пора делать смузи.");    }  });// 3. when: ждет, пока условие не станет true, и выполняет код ОДИН раз.// Можно использовать прямо с async/await!async function waitForApples() {  await when(() => store.fruits.includes("apple"));  console.log("Наконец-то в корзину добавили яблоко! Можно продолжать работу.");}waitForApples();store.count++; // autorun выведет логstore.fruits.push("banana", "orange"); // autorun выведет логstore.fruits.push("apple"); // autorun выведет лог, и сработает when!

Больше не нужно оперировать хуками в компонентах, чтобы просто выполнить действие по условию изменения данных в сторе. Я показал самые основные используемые функции, которая предоставляет библиотека, но есть и другие, которые больше уже необходимы для разных тонкостей (например untracked или onBecomeObserved)

5. Нативная работа с коллекциями (Map и Set) без боли

Если вы когда-нибудь пробовали хранить Map или Set в классическом Redux или Zustand, то знаете, какая это боль. Инструменты, завязанные на строгой иммутабельности, заставляют вас копировать всю коллекцию целиком при добавлении одного элемента. Это медленно, некрасиво и неудобно. MobX же умеет делать стандартные коллекции JS полностью реактивными. Давайте расширим наш стор:

const store = makeAutoObservable({  count: 0,  fruits: [],  // Добавляем коллекции прямо сюда  fruitPrices: new Map(),  selectedFruits: new Set(),});// Работаем с ними нативно, как в ванильном JS:store.fruitPrices.set("apple", 150);store.selectedFruits.add("apple");// Компоненты, которые используют эти данные, обновятся автоматически!store.fruitPrices.delete("banana");store.selectedFruits.clear();

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

Но это не серебряная пуля

К моему личному сожалению, даже у инструмента, к которому я отношусь с любовью, есть свои недостатки.

Идеального кода не существует, поэтому давайте честно поговорим про минусы:

1. SSR (Server-Side Rendering)

Он есть, и его вполне можно использовать, но рецептов того, как его «правильно готовить», в сети катастрофически мало. В отличие от того же Redux с его Next.js-обертками, где гидрация состояния расписана в каждом туториале, с MobX вам, скорее всего, придется немного поизобретать велосипед при передаче стейта с сервера на клиент.

2. Мало кроссфреймворк-биндингов

Если вы пишете на React — всё отлично (спасибо mobx-react-lite). Но если вы захотите переиспользовать свою бизнес-логику в проектах на Solid, Vue или Svelte, вы столкнетесь с тем, что готовых и популярных оберток под них практически нет.

3. Экосистема

Для того же Redux есть готовые npm-пакеты почти под любой чих (персист, роутинг, отмена запросов). В мире MobX ситуация иная. Большинство команд пишется свои решения и велосипеды внутри проектов. Потому что чаще всего это просто обычный JS/TS код, где нужные поля помечены как реактивные, и всё. Безусловно, есть крупные готовые решения вроде mobx-state-tree(MST), который предлагает жесткий, структутированный подход со снэпшотами и тайм-тревелом, а также есть официальный набор утилит mobx-utils, а еще mobx-persist-store, mobx-react-form, mobx-form-lite. Но часто этих готовых крупных решений бывает недостаточно.
Поэтому я активно стараюсь расширять опенсорс-экосистему вокруг библиотеки (если интересно, можете посмотреть мои пакеты вроде mobx-route, mobx-view-model, mobx-tanstack-query, mobx-tanstack-query-api, mobx-web-api и другие)

4. Размер бандла

За магию нужно платить. Библиотека добавит к вашему бандлу лишние килобайты. Это абсолютно не критично для большинства enterprise-приложений, но если вы разрабатываете проект, где идет борьба за каждый скачанный байт, вес MobX придется учитывать.

5. Потребление памяти

Да, оно выше. Под капотом MobX оборачивает ваши объекты и массивы в Proxy (конечно же не всегда) и создает дополнительные внутренние структуры для отслеживания зависимостей. Для обычной работы с формами или списками на пару сотен элементов разницы вы не заметите. Но если вы попытаетесь засунуть в makeAutoObservable массив на 500 000 сложных объектов, вкладка браузера может неприятно удивить вас потреблением оперативки, но одна обёртка этого поля в observable.ref снизит такое потребление.

Посмотрим примеры?

В этой секции я хочу показать немного кода и практических задач с решением на MobX в связке с React. Все примеры буду стараться показать максимально притивными и простыми, клянусь никаких кверей мутаций и MVVM 🙂

Счётчик

Реализаций конечно огромная куча, но я постараюсь показать максимально аккуратный пример

import { makeAutoObservable } from "mobx";import { observer } from "mobx-react-lite";const createCounter = () =>  makeAutoObservable({    value: 0,    inc() {      this.value++;    },    dec() {      this.value--;    },  }, { autoBind: true });const counter = createCounter();export const Counter = observer(() => {  return (    <div>      <button type="button" onClick={counter.dec}>        −      </button>      <span>{counter.value}</span>      <button type="button" onClick={counter.inc}>        +      </button>    </div>  );});

Асинхронные запросы и UI

import { makeAutoObservable } from "mobx";import { observer } from "mobx-react-lite";class FruitsStore {  data: unknown = null;  isLoading = false;  error: unknown = null;  constructor() {    makeAutoObservable(this, {}, { autoBind: true });  }  async load() {    this.isLoading = true;    this.error = null;    try {      const response = await fetch("/api/fruits");      if (!response.ok) {        throw new Error(String(response.status));      }      this.data = await response.json();    } catch (error) {      this.error = error;    }    this.isLoading = false;  }}const fruits = new FruitsStore();export const Fruits = observer(() => {  return (    <div>      <button type="button" onClick={fruits.load} disabled={fruits.isLoading}>        {fruits.isLoading ? "Грузим…" : "Загрузить фрукты"}      </button>      {fruits.error != null && <p>{String(fruits.error)}</p>}      {fruits.data != null && <pre>{JSON.stringify(fruits.data, null, 2)}</pre>}    </div>  );});

Ленивый счётчик времени

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

Идея: завернуть время в кастомный observable через createAtom — интервал заводим только пока кто-то реально читает это поле (например, пока блок в React смонтирован).

import { observer } from "mobx-react-lite";import { createAtom } from "mobx";import { useState } from "react";export const createTime = () => {  let intervalId: number | undefined;  const atom = createAtom(    "timeAtom",    () => intervalId = setInterval(() => atom.reportChanged(), 1000),    () => clearInterval(intervalId!)  );  return {    get now() {      atom.reportObserved();      return Date.now();    },    get label() {      return new Date(this.now).toLocaleString();    }  };};const time = createTime();console.log(time.now); // выведет Date.now но setInterval не будет вызван!export const Time = observer(() => {  const [visible, setVisible] = useState(false);  return (    <div>      <button        type="button"        onClick={() => {          setVisible(!visible);        }}      >        {visible ? "Скрыть время" : "Показать время"}      </button>      {visible && <p>{time.label}</p>}    </div>  );});

Наверное, вы спросите: а в чем тут прикол? ​А прикол в том, что если вы просто прочитаете time.label в обычном JS-коде вне реактивного контекста, вы получите текущее время, но setInterval даже не запустится из-за отсутствия активных наблюдателей. Но как только мы отрисуем React-компонент и нажмём «Показать время» — вот только тогда и запустится интервал.​О том, как именно под капотом работает эта магия определения контекста, я, возможно, напишу отдельную мини-статью.

На этом всё!

Спасибо, что дочитали мою первую статью на Хабре до конца. Надеюсь, вам было интересно и познавательно 🥰​

Так почему же MobX — это приправа? Если представить наш проект как большое блюдо, то без правильных специй оно получится пресным и невкусным. Так и с кодом: без удобной реактивности проект тяжело «переваривать» и поддерживать. MobX добавляет ту самую остроту и вкус, с которыми разработка становится в радость.

ссылка на оригинал статьи https://habr.com/ru/articles/1043240/