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

Меня зовут Сергей Волков, я фронтенд-разработчик в компании 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;
  }
});

// UI
const 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;

// UI
const 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;


// UI
const 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);

// UI
const [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 добавляет ту самую остроту и вкус, с которыми разработка становится в радость.

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