- PVSM.RU - https://www.pvsm.ru -

Привет!
Меня зовут Сергей Волков, я фронтенд-разработчик в компании VK. Мы используем 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 безоговорочным фаворитом:
Что нужно, чтобы изменить реактивное значение? Правильно — использовать стандартные механизмы языка.
const store = makeAutoObservable({
calls: 0,
fruits: [] as string[],
get count() {
return this.fruits.length;
}
});
// UI
// const Comp = observer(() => {
const addFruit = () => {
store.calls++;
store.fruits.push("apple");
};
С базовой работой MobX изменения "observable" значений без "action" будут вызывать предупреждения. Не буду заострять на этом внимание, потому что подробнее об этом можно прочитать на странице самой документации. [1] Это отключается с помощью:
import { configure } from "mobx";
configure({ enforceActions: 'never' });
Давайте посмотрим как это выглядит в классическом 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.
Вам больше не нужно создавать копии объектов на каждое изменение, оперировать спред операторами(если в этом нет необходимости) или обязательно тянуть в проект утилиты вроде 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, чтобы подарить реактивность глубоко вложенным данным, оставляя вам чистый, линейный и легко читаемый код бизнес-логики.
MobX не заставляет пихать всё состояние приложения в единое монолитное дерево. Инструмент подстраивается под вас, а не вы под него. Нужны глобальные доменные сторы? Пожалуйста. Хотите изолировать логику в локальных View-моделях под конкретный сложный компонент? Легко. Вы просто инкапсулируете данные, computed-свойства и методы-экшены в аккуратные классы так, как этого требует ваша архитектура, а не ограничения/требования стейт-менеджера.
В 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)
Если вы когда-нибудь пробовали хранить 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.
К моему личному сожалению, даже у инструмента, к которому я отношусь с любовью, есть свои недостатки.
Идеального кода не существует, поэтому давайте честно поговорим про минусы:
Он есть, и его вполне можно использовать, но рецептов того, как его «правильно готовить», в сети катастрофически мало. В отличие от того же Redux с его Next.js-обертками, где гидрация состояния расписана в каждом туториале, с MobX вам, скорее всего, придется немного поизобретать велосипед при передаче стейта с сервера на клиент.
Если вы пишете на React — всё отлично (спасибо mobx-react-lite). Но если вы захотите переиспользовать свою бизнес-логику в проектах на Solid, Vue или Svelte, вы столкнетесь с тем, что готовых и популярных оберток под них практически нет.
Для того же Redux есть готовые npm-пакеты почти под любой чих (персист, роутинг, отмена запросов). В мире MobX ситуация иная. Большинство команд пишется свои решения и велосипеды внутри проектов. Потому что чаще всего это просто обычный JS/TS код, где нужные поля помечены как реактивные, и всё. Безусловно, есть крупные готовые решения вроде mobx-state-tree(MST) [2], который предлагает жесткий, структутированный подход со снэпшотами и тайм-тревелом, а также есть официальный набор утилит mobx-utils [3], а еще mobx-persist-store [4], mobx-react-form [5], mobx-form-lite [6]. Но часто этих готовых крупных решений бывает недостаточно.
Поэтому я активно стараюсь расширять опенсорс-экосистему вокруг библиотеки (если интересно, можете посмотреть мои пакеты вроде mobx-route [7], mobx-view-model [8], mobx-tanstack-query [9], mobx-tanstack-query-api [10], mobx-web-api [11] и другие)
За магию нужно платить. Библиотека добавит к вашему бандлу лишние килобайты. Это абсолютно не критично для большинства enterprise-приложений, но если вы разрабатываете проект, где идет борьба за каждый скачанный байт, вес MobX придется учитывать.
Да, оно выше. Под капотом 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>
);
});
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 добавляет ту самую остроту и вкус, с которыми разработка становится в радость.
Автор: js2me
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/mvvm/452619
Ссылки в тексте:
[1] странице самой документации.: https://mobx.js.org/configuration.html#enforceactions
[2] mobx-state-tree(MST): https://www.npmjs.com/package/mobx-state-tree
[3] mobx-utils: https://www.npmjs.com/package/mobx-utils
[4] mobx-persist-store: https://www.npmjs.com/package/mobx-persist-store
[5] mobx-react-form: https://www.npmjs.com/package/mobx-react-form
[6] mobx-form-lite: https://www.npmjs.com/package/mobx-form-lite
[7] mobx-route: https://js2me.github.io/mobx-route
[8] mobx-view-model: https://js2me.github.io/mobx-view-model
[9] mobx-tanstack-query: https://js2me.github.io/mobx-tanstack-query
[10] mobx-tanstack-query-api: https://js2me.github.io/mobx-tanstack-query-api
[11] mobx-web-api: https://js2me.github.io/mobx-web-api
[12] Источник: https://habr.com/ru/articles/1043240/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1043240
Нажмите здесь для печати.