
Привет, Хабр!
Меня зовут Сергей Волков, я фронтенд-разработчик в компании 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 добавляет ту самую остроту и вкус, с которыми разработка становится в радость.