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