The post has been translated automatically. Original language: Russian
Taming the Complexity of a Large React Console: an IoC Container and MobX Instead of a God Object
Authors: DNA Payments frontend team
How it all started
Nobody writes glowing blog posts about projects like this. You never see "built an admin panel for a payment platform over the weekend." And that is a shame, because this is exactly where real frontend complexity lives, the kind that makes a developer's eye twitch by Friday.
We build a console for a payment platform. In plain terms, it is a big panel through which people manage money: they look at transactions, analytics, statements, invoices, they create users and permissions, go through onboarding, configure notifications. From here on we deliberately name no product, no clients, no partners, because it does not matter. Everything we describe carries over cleanly to any large panel. We have been building consoles like this for years, so this comes from real scars, not from a textbook.
An application like this lives for years, and that changes everything. It is big: dozens of sections, hundreds of screens, each with its own filters, tables, exports, charts. Different people work on it in turns: someone left long ago, but their code is still running in production and still has to work. And it is stuffed to the brim with state: the selected merchant, the date range, active filters, reference books, permissions, the profile. All of this has to be shared between screens, kept in sync, and not lost on transitions.
This state and its connections are the main source of pain. While you have ten screens, literally anything works. When you have two hundred, the usual compromises start taking their revenge: it is unclear who changes the data and when, a tiny edit drags a cascade of regressions behind it, and a newcomer is afraid to touch anything for weeks. We knew these rakes well from earlier projects, so in this console we laid a different foundation from the very start: inversion of control (IoC) and dependency injection (DI) on top of reactive MobX. Below we go through why it is needed, how it is built in code, and where the reasonable boundaries run. With code, with examples, and without the gloss.
The classic root store and how it takes revenge
Almost every large MobX application starts the same way out of inertia, with a root store. You create a single class, let us call it AppStore, it holds references to all the other stores and instantiates them by hand. Something like this:
class AppStore {
public authStore: AuthStore
public routerStore: RouterStore
public handbooksStore: HandbooksStore
public rangePickerStore: RangePickerStore
public transactionChannelsStore: TransactionChannelsStore
// ...and dozens more fields like these
private initStores() {
this.handbooksStore = new HandbooksStore()
this.rangePickerStore = new RangePickerStore()
this.onboardingProcessesStore = new ProcessesStore(
this.handbooksStore,
this.rangePickerStore
)
this.startApplicationStore = new StartApplicationStore(this)
// ...and so on, by hand, by hand
}
}
const AppStoreInstance = new AppStore()
export { AppStoreInstance as AppStore }
And at first this is genuinely convenient. There is one place where the whole application lives. You reach any store through a dot: appStore.handbooksStore. The connections are spelled out explicitly and read top to bottom. While the project is small this is even elegant. The trouble is that it does not stay small for long, and then it starts to hurt. Steadily worse. We have watched this plot play out more than once, which is why we know it by heart.
First, coupling. The root object knows about everything in the world. Just to construct it, you have to drag in dozens of imports from every corner of the app. The init file swells to indecent sizes, and AppStore itself turns into a node that every single screen hangs off. Any store that gets this in its constructor automatically gets access to the whole application state. Formally StartApplicationStore depends on AppStore, but in practice on three of its fields, and you cannot see that from the code. So you sit there and guess what will break if you touch one field.
Then tests. Or rather, their absence. To test one store in isolation you have to spin up the entire root object or mold a mock of it. And since the constructors chew on the whole this, the mock has to pretend to be half the application. In the end unit tests either do not get written at all, or quietly mutate into integration tests.
Next, reuse, and this is where it gets genuinely frustrating. A console is full of repeating pieces of state: merchant selection, date selection, a table filter. The logic is the same, but each screen needs its own separate instance: the filter in analytics must know nothing about the filter in monitoring. In the God object model the options are poor: either breed twin fields (analyticsRangePicker, monitoringRangePicker, invoicesRangePicker…), or build a homemade registry of instances. Both quickly turn into something you are ashamed to look at.
And finally, the lifecycle, which in this scheme simply does not exist. Everything is born at startup and lives forever. There is nothing to say "this store is needed only on this page and should reset on the next visit." State leaks between screens: the user set a filter, left, came back, and the filter is still hanging there when it should have been cleared. A plot familiar from bug reports.
This is exactly why in the console we did not build yet another manual pile of references from the start. Instead we took a mechanism that manages both dependencies and the lifecycle of objects. That is IoC.
What IoC actually is and why we needed it
If you strip away the theory, inversion of control works like this: an object stops creating what it needs itself and simply says "I need such and such a neighbor." Who assembles that neighbor, how, and when is no longer its concern. Dependencies are usually delivered in two ways: through the constructor or through the container.
It sounds abstract, but for us three very down-to-earth things stood behind it.
First, interface apart from implementation. A component asks not for the class MerchantSelectStore but for the interface IMerchantSelectStore. Who stands behind it and how it is assembled is the container's business. The hard coupling breaks: you can swap the implementation, wrap it, mock it, and the consumer will not even notice.
Second, a managed object lifecycle. The container itself decides when to create an object, whether it lives as a single instance for the whole application or is born anew for each request. This is exactly the lever you need to keep control over state that would otherwise leak between screens.
Third, the whole assembly lives in one place. Who depends on whom, who is created how, all of it is gathered in the container configuration rather than smeared across the constructors of hundreds of classes. A funny paradox: the more you centralize the configuration, the more scattered and small the code itself becomes.
We took a ready tool: InversifyJS, a mature and proven IoC container for TypeScript built on decorators and metadata. On top of it we have a thin wrapper of our own that hides some boilerplate and simplifies registration and subscription. But under the hood it is still Inversify, so from here on in the examples we show the wrapper functions register and subscribe, think of them as sugar over the standard API.
The boring but important setup
There is an unpleasant truth about DI in TypeScript: it rests on two almost invisible things, decorators and type reflection. And until they are configured, nothing works, and the errors are maximally cryptic.
At runtime the container needs types that in the code exist only at the TypeScript level and usually evaporate after compilation. Getting them to survive into runtime is the job of reflect-metadata together with decorator metadata emission. On Babel this is several plugins: for the decorators themselves, for parameter decorators, for type metadata. And in tsconfig.json you need to turn on experimentalDecorators and emitDecoratorMetadata. One more thing: the reflect-metadata import must be the very first in the application, before any code that uses decorators. Otherwise the metadata simply will not be collected.
This is the case where a single line can eat a whole day. The symptom is always the same: out of nowhere you get "cannot resolve dependency," and you sit there, dumbfounded, re-reading your store, while the culprit is the import order or a forgotten plugin. You configure this once, write it down in the README, and never come back to it.
Tokens instead of strings and classes
The first real choice inside DI is what to mark a dependency with. The most obvious option, cling to the class: "give me MerchantSelectStore." But that is exactly the hard hook into the implementation we stay away from, plus you cannot set up several independent registrations of one class.
So we use symbol tokens. For each injection point we create a unique identifier, and the container matches interface to implementation precisely by it:
export const STORE_TYPES = {
AppStore: Symbol.for('AppStore'),
RouterStore: Symbol.for('RouterStore'),
HandbooksStore: Symbol.for('HandbooksStore'),
RangePickerStore: Symbol.for('RangePickerStore')
}
The symbol here works as a contract. The consumer says "give me the implementation under the token RangePickerStore," and which class hides behind it is known only to the container configuration. Three upsides at once: type safety (the token is parameterized by an interface), no name collisions, and, the tastiest part, the ability to register one class under a bunch of different tokens. We will come back to this trick, because it turned out to be the whole reason we did this.
How we register stores
The container assembly lives in a separate module that runs once at startup. First the container itself comes up, then the registrations follow. We use three approaches, each for its own task.
The simplest one: bind a token to a class and ask it to be kept as a single instance.
register<IRangePickerContainerStore>(STORE_TYPES.RangePickerStore)
.to(RangePickerStoreInjectable)
.inSingletonScope()
inSingletonScope means "create it once, then hand back the same one." For genuinely shared goods, like reference books, the router or the global config, it is just what the doctor ordered.
Sometimes an object already exists and you just need to place it into the container as a constant. That is how we register ready-made global objects, for example, so the code pulls them through DI uniformly with everything else rather than through a direct singleton import.
register<IAppStore>(STORE_TYPES.AppStore).toConstantValue(AppStoreInstance)
And the most interesting one: factories. When a store needs other stores to be born, we register it through a dynamic value and pull the dependencies out of the container right inside:
register<IMainLayoutStore>(STORE_TYPES.MainLayoutStore)
.toDynamicValue(
(context) =>
new MainLayoutStore(
context.container.get(STORE_TYPES.RouterStore)
)
)
.inSingletonScope()
This is where dependency inversion bares its teeth. MainLayoutStore does not import RouterStore and has no idea how it is created. It simply accepts a ready implementation of the router interface into its constructor. All the wiring is gathered in one place, in the factory. If the router changes tomorrow, the consumers will not even flinch.
Yes, this whole configuration looks like a long list of registrations, and the file comes out sizable. But it is a fundamentally different kind of sizable from the God object. There is no logic here, only a dry "token, then how to build it." You read it top to bottom, find a token in a second, and review it line by line without tears. The complexity did not go anywhere, it simply moved from execution into configuration, where you can live with it.
One class, many lives
And now that very trick, the whole reason we did this. A console is full of repeating pieces of state: merchant selection, date range, a table filter. The logic is one and the same, but each screen needs its own isolated instance.
With tokens this is solved with indecent elegance. We write one class RangePickerStoreInjectable, and then register it as many times as we have independent usage sites, each time under its own token:
register<IRangePickerContainerStore>(RangePickerSymbols.analytics)
.to(RangePickerStoreInjectable)
.inSingletonScope()
register<IRangePickerContainerStore>(RangePickerSymbols.monitoring)
.to(RangePickerStoreInjectable)
.inSingletonScope()
register<IRangePickerContainerStore>(RangePickerSymbols.invoices)
.to(RangePickerStoreInjectable)
.inSingletonScope()
Each registration yields a singleton, but a singleton within its own token. The container holds three independent instances of one class, and state does not flow between them. The analytics component asks for the implementation under the token analytics, the monitoring component under monitoring, and they physically cannot step on each other's toes.
In one stroke this removes a whole class of bugs that in the naive scheme get cured with copy-pasted fields and prayer. We get reuse of code without reuse of state, and that is a rare thing: usually you have to pick one or the other. One class, a single source of logic. Many tokens, many separate lives of that logic, none of them aware of the others. When you first see this on a real section, the relief is almost physical. This is the pattern that pays back the whole container effort first and foremost.
MobX inside an injectable store
DI answers who creates whom and how long it lives. Reactivity, the part where the interface updates itself when data changes, is still MobX's job. And they live together beautifully: the container assembles the store, and the store declares its observable state inside itself.
Here is that very date-range store that we breed under a pile of tokens:
@injectable()
export class RangePickerStoreInjectable implements IRangePickerContainerStore {
period: PeriodType = null
startDate: Moment = null
endDate: Moment = null
constructor() {
makeObservable(this, {
period: observable,
startDate: observable,
endDate: observable,
setDates: action.bound,
setPeriod: action.bound,
clear: action
})
}
setDates(startDate: Moment, endDate: Moment) {
this.startDate = startDate
this.endDate = endDate
this.period = getPeriod(startDate, endDate)
}
setPeriod(period: PeriodType) {
const { startDate, endDate } = getDates(period)
this.startDate = startDate
this.endDate = endDate
this.period = period
}
clear = () => {
this.period = null
this.startDate = null
this.endDate = null
}
}
Notice a couple of things. The @injectable() decorator says: this class can be governed by the container. makeObservable in the constructor spells out in plain text what is observable, what is an action, and what is derived (computed). We deliberately chose the explicit makeObservable over the "magical" makeAutoObservable. In large stores the explicit listing works as documentation and does not let you accidentally turn a private helper into an observable field, and that, believe us, does hurt.
A word about action.bound. Store methods sooner or later fly into components as handlers: into onChange, into onClick. Without a bound context such a method, torn away from the object, loses this. action.bound closes the question right at the declaration: both a MobX action and a firmly nailed context. A trifle, yet think how many evenings it saves on debugging the classic "why is my this suddenly undefined."
And the main takeaway: the class knows nothing about DI beyond a single decorator. It does not drag in the container, does not reach into global state, has no clue about its neighbors. Just a clean carrier of state and the logic to change it. Testing stores like these is a pleasure: create one directly, call a method, check a field.
How it reaches the components
The assembled state still has to be delivered to React, and here it is important not to lose all the decoupling we won and not to slide back into prop-drilling, where you thread stores through ten levels of props.
A component asks for the implementation it needs by token:
const rangePickerStore = subscribe<IRangePickerContainerStore>(
RangePickerSymbols.analytics
)
subscribe goes into the container and returns whatever is registered under the token. The component does not import a concrete class, does not know whether it is a singleton or not, does not take part in the assembly, it simply names the contract. For all of this to re-render reactively, the component is wrapped in observer from mobx-react, and from there it is business as usual: read fields in the markup, call the action methods in handlers.
The difference from the naive scheme is like night and day. There a component receives the whole appStore and reaches the field it needs through it, picking up the keys to the entire apartment along the way. Here it declares a narrow, honest, typed dependency on a single interface. You open a component and immediately see which state it depends on. Not from tribal knowledge of "what lives where," but from the code.
Persistence: briefly and carefully
Part of the state has to survive a page reload: the interface language, a few settings. For that we have mobx-persist on top of storage in IndexedDB via localForage. The marked fields save themselves and rehydrate on startup, that is, restore from storage.
And here you need discipline, otherwise it will come back to bite you. The temptation to "persist a bit more, just in case" leads straight to subtle bugs: after a deploy stale data surfaces, formats drift apart, the user admires a week-old state. So we keep persisted state to a minimum, only what truly must survive the session. And we are very careful about the moment of hydration: the asynchronous restore must finish before the code that depends on it starts running. Races at this junction produce the nastiest, flickering bugs, the ones that do not reproduce on your machine but reliably break at the client's.
No fanaticism
Going DI-first does not mean everything has to be wrapped into an injectable store. That is the trap in the opposite direction: you can get so carried away with purity that you wrap a two-field store in a factory purely on principle.
We keep the balance. Something genuinely global and simple happily lives as a constant in the container or as a single shared store, and that is a deliberate choice, not a shortfall. The border runs along common sense: what really repeats or has to be isolated per screen gets its own token and its own lifecycle. Everything else does not need to be inflated. The container is a tool for us, not a religion, and we treat it accordingly.
And one more rule we internalized: do not chase a beautiful but unreachable uniformity. A working, pragmatic mix beats an endless refactor for the sake of purity that never reaches production. Strictly by the book looks impressive at a conference, but a product is moved by releases, not by the book.
The rakes we collected
There are no perfect solutions, and ours has its price too. Here is what cost us our nerves.
Circular dependencies. DI does not abolish them, but it makes them loud. If A pulls B through a factory and B pulls A, the container honestly falls over at resolution. And that is a blessing: a cycle that in the God object would have quietly rotted for years gets exposed here at once. Once a filter store and a table store surfaced for us that were quietly tugging at each other; in the naive scheme it would have lived forever and fired at the worst possible moment. The cure is architectural: extract the common part into a third store or fetch the dependency lazily. But it requires thinking about connections up front, not after the fact.
Initialization order. reflect-metadata first, bring up the container before the first access to the stores, wait for hydration before the dependent code starts. These are all invisible threads, and tearing any one of them gets you a cryptic crash. We gathered all these steps into one explicit, documented startup module, so the order is in plain sight and nobody nudges it by accident.
Singletons that want to become god. A singleton is convenient and therefore dangerous: a store shared across the whole application easily turns into a small God object, accumulating references and state that belong elsewhere. The rule we set for ourselves: make a singleton only deliberately, not by default. Anything tied by meaning to a screen is registered under its own token and/or explicitly reset on leaving the page.
Decorators versus the bundler. Decorator metadata and aggressive tree-shaking sometimes go to war: the bundler is inclined to "optimize away" a class that is nowhere explicitly imported except in the container configuration. We had to make sure the registrations actually reached the container and not to rely on the implicit side effects of imports.
The learning curve. Container, symbols, factories, reactivity: you pay for all of it with onboarding time. It is not enough to tell a newcomer "where things live," you have to explain "why it is built this way." We covered this with pattern documentation and live examples: how to set up a new store, how to hang it on several tokens, how to subscribe in a component. Once they have walked the route once, people start moving faster than in the naive model. But the first time you have to lead them by the hand.
Where it landed
Betting on IoC/DI on top of MobX from the very start gave us exactly what we did it for. The stores are small, isolated, testable. Dependencies are honest: you look at a class or a component and see what it actually needs. Reuse of state is not a headache: one class, many tokens, no mixing of instances. The lifecycle is under control: we decide what lives forever and what is born anew. And, importantly, all of it came without a big turn: we did not pay for a migration and did not freeze releases for a rebuild, because we laid the foundation right away.
Only this is not a silver bullet. There is a price: an extra layer of abstraction, fragility at the seam of decorators and the bundler, a steeper onboarding. For an app with a dozen screens all this machinery is shooting sparrows with a cannon, an honest root store there is simpler and clearer, and dragging a container into it for the sake of a checkbox is something we would not advise. IoC pays off where the complexity is real and keeps growing, where several teams work on the code, and where state is the central entity of the product. Our console is exactly such a case, and judging by how calmly it grows, the bet has paid off.
If we were starting over, we would swap the order of two things. First, we would agree on conventions for tokens and lifecycle even more firmly from the very start: a single scheme for naming symbols and a "singletons only deliberately" rule would have saved us a couple of painful spots. Second, we would fix and write down the infrastructure build setup earlier, so newcomers would not lose days to "magical" dependency-resolution errors.
And the main conclusion is not even about libraries. The architecture of a large frontend is not about which "correct" state-management library to pick. It is about managing connections and the lifecycle of objects. MobX does a wonderful job of making state reactive, but on its own it stays silent on the question "who creates whom, who depends on whom, and how long it lives." IoC answers that question. The application's complexity does not disappear, you cannot kill it at all. But you can drag it to where it is convenient to steer: out of murky connections in execution into an explicit, readable, reviewable configuration. And the ability to make complexity visible and manageable is what grown-up frontend architecture is.
Как приручить сложность крупной React-консоли: IoC-контейнер и MobX вместо God-объекта
Авторы: фронтенд-команда DNA Payments
С чего всё началось
Про такие проекты редко пишут восторженные статьи. Никто не выкладывает в блог «сделал за вечер админку платёжной платформы». А зря. Потому что именно в них и живёт настоящая фронтенд-сложность, та самая, от которой у разработчика к пятнице дёргается глаз.
Мы делаем консоль платёжной платформы. Если совсем по-простому, это большая панель, через которую люди управляют деньгами: смотрят транзакции, аналитику, выписки, инвойсы, заводят пользователей и права, проходят онбординг, настраивают уведомления. Дальше мы намеренно не называем ни продукт, ни клиентов, ни партнёров: это и не нужно. Всё, о чём пойдёт речь, спокойно переносится на любую большую панель. Такие консоли мы делаем не первый год, так что говорим не по книжкам, а по набитым шишкам.
Такое приложение живёт годами, и это меняет всё. Оно большое: десятки разделов, сотни экранов, и на каждом свои фильтры, таблицы, экспорты, графики. Над ним по очереди работают разные люди: кто-то давно ушёл, а его код всё ещё крутится в проде и должен работать. И оно по горло набито состоянием: выбранный мерчант, диапазон дат, активные фильтры, справочники, права, профиль. Всё это надо шарить между экранами, держать в синхроне и не терять при переходах.
Вот это состояние и его связи и есть главный источник боли. Пока экранов десять, работает вообще что угодно. Когда их двести, привычные компромиссы начинают мстить: непонятно, кто и когда меняет данные, крохотная правка тянет за собой каскад регрессий, а новичок неделями боится хоть что-то тронуть. Эти грабли мы хорошо знали по прошлым проектам, поэтому в этой консоли с самого начала заложили другую основу: инверсию управления (IoC) и внедрение зависимостей (DI) поверх реактивного MobX. Дальше разберём, зачем это нужно, как устроено в коде и где проходят разумные границы. С кодом, с примерами и без глянца.
Классический корневой стор и чем он мстит
Почти любое большое MobX-приложение по инерции начинается одинаково, с корневого стора. Заводится один класс, назовём его AppStore, он держит ссылки на все остальные сторы и создаёт их руками. Что-то в духе:
class AppStore {
public authStore: AuthStore
public routerStore: RouterStore
public handbooksStore: HandbooksStore
public rangePickerStore: RangePickerStore
public transactionChannelsStore: TransactionChannelsStore
// ...и ещё десятки таких полей
private initStores() {
this.handbooksStore = new HandbooksStore()
this.rangePickerStore = new RangePickerStore()
this.onboardingProcessesStore = new ProcessesStore(
this.handbooksStore,
this.rangePickerStore
)
this.startApplicationStore = new StartApplicationStore(this)
// ...и так далее, руками, руками
}
}
const AppStoreInstance = new AppStore()
export { AppStoreInstance as AppStore }
И поначалу это действительно удобно. Есть одно место, где живёт всё приложение. До любого стора дотягиваешься через точечку: appStore.handbooksStore. Связи прописаны явно, читаются сверху вниз. Пока проект маленький, это даже красиво. Беда в том, что маленьким он остаётся недолго, а дальше начинает болеть. По нарастающей. Этот сюжет мы видели не раз, поэтому и знаем его наизусть.
Сначала про связность. Корневой объект знает про всё на свете. Чтобы просто его собрать, надо натащить десятки импортов со всех углов приложения. Файл инициализации пухнет до неприличных размеров, а сам AppStore превращается в узел, за который держится буквально каждый экран. Любой стор, получив в конструктор this, автоматически получает доступ ко всему состоянию приложения. Формально StartApplicationStore зависит от AppStore, а по факту от трёх его полей, но по коду этого не видно. И вот сидишь и гадаешь, что сломается, если тронуть одно поле.
Потом тесты. Точнее, их отсутствие. Чтобы проверить один стор в изоляции, надо поднять весь корневой объект или слепить его мок. А раз конструкторы жуют this целиком, мок должен притворяться половиной приложения. В итоге юнит-тесты либо не пишутся совсем, либо тихо мутируют в интеграционные.
Дальше переиспользование, и вот тут по-настоящему обидно. В консоли куча повторяющихся кусочков состояния: выбор мерчанта, выбор дат, фильтр таблицы. Логика одна, но на каждом экране нужен свой, отдельный экземпляр: фильтр в аналитике не должен ничего знать про фильтр в мониторинге. В модели God-объекта выбор так себе: либо плодить поля-близнецы (analyticsRangePicker, monitoringRangePicker, invoicesRangePicker…), либо городить самопальный реестр инстансов. Оба варианта быстро превращаются в то, на что смотреть стыдно.
И наконец жизненный цикл, которого в такой схеме просто нет. Всё рождается на старте и живёт вечно. Сказать «этот стор нужен только на этой странице и должен обнуляться при следующем заходе» нечем. Состояние протекает между экранами: пользователь выставил фильтр, ушёл, вернулся, а фильтр всё ещё висит, хотя должен был сброситься. Знакомый по багрепортам сюжет.
Именно поэтому в консоли мы с самого начала не стали городить очередную ручную свалку ссылок. Вместо неё взяли механизм, который управляет и зависимостями, и жизнью объектов. Это IoC.
Что вообще такое IoC и зачем он нам
Если убрать теорию, инверсия управления работает так: объект перестаёт сам создавать то, что ему нужно, и начинает просто говорить «мне нужен вот такой-то сосед». А кто, как и когда этого соседа соберёт, уже не его забота. Внедряют зависимости обычно двумя путями: через конструктор или через контейнер.
Звучит абстрактно, но для нас за этим стояли три очень приземлённые вещи.
Во-первых, интерфейс отдельно, реализация отдельно. Компонент просит не класс MerchantSelectStore, а интерфейс IMerchantSelectStore. Кто там за ним стоит и как собран, решает контейнер. Жёсткая связь рвётся: реализацию можно подменить, обернуть, замокать, и потребитель этого даже не заметит.
Во-вторых, управляемая жизнь объектов. Контейнер сам решает, когда объект создать, живёт ли он в одном экземпляре на всё приложение или рождается заново под каждый запрос. Именно этот рычаг и нужен, чтобы держать под контролем состояние, которое иначе протекает между экранами.
В-третьих, вся сборка живёт в одном месте. Кто от кого зависит, кто как создаётся, всё собрано в конфигурации контейнера, а не размазано по конструкторам сотен классов. Забавный парадокс: чем сильнее централизуешь конфигурацию, тем более разрозненным и маленьким становится сам код.
Инструмент мы взяли готовый: InversifyJS, взрослый и проверенный IoC-контейнер для TypeScript на декораторах и метаданных. Поверх него у нас лежит тонкая своя обёртка, которая прячет часть шаблонного кода и упрощает регистрацию с подпиской. Но под капотом всё тот же Inversify, так что дальше в примерах мы показываем обёрточные register и subscribe, считайте их сахаром над стандартным API.
Скучная, но важная настройка
Есть неприятная правда про DI на TypeScript: он держится на двух почти невидимых штуках, декораторах и рефлексии типов. И пока их не настроил, ничего не работает, а ошибки при этом максимально загадочные.
Контейнеру в рантайме нужны типы, которые в коде существуют только на уровне TypeScript и после компиляции обычно испаряются. За то, чтобы они дожили до рантайма, отвечает reflect-metadata вместе с эмиссией метаданных декораторов. На Babel это несколько плагинов: для самих декораторов, для параметр-декораторов, для метаданных типов. А в tsconfig.json нужно включить experimentalDecorators и emitDecoratorMetadata. И ещё: импорт reflect-metadata обязан быть самым первым в приложении, до любого кода с декораторами. Иначе метаданные просто не соберутся.
Это тот самый случай, когда одна строчка способна сожрать целый день. Симптом всегда один и тот же: на ровном месте вылетает «не могу разрешить зависимость», и ты сидишь, тупишь, перечитываешь свой стор, а виноват порядок импортов или забытый плагин. Настраивается это один раз, пишется в README, и больше туда можно не возвращаться.
Токены вместо строк и классов
Первый настоящий выбор внутри DI: чем помечать зависимость. Самый очевидный вариант, цепляться за класс: «дай мне MerchantSelectStore». Но это ровно тот жёсткий крючок к реализации, от которого мы уходим, да ещё и невозможно завести несколько независимых регистраций одного класса.
Поэтому мы используем символы-токены. На каждую точку внедрения заводим свой уникальный идентификатор, и именно по нему контейнер сводит интерфейс с реализацией:
export const STORE_TYPES = {
AppStore: Symbol.for('AppStore'),
RouterStore: Symbol.for('RouterStore'),
HandbooksStore: Symbol.for('HandbooksStore'),
RangePickerStore: Symbol.for('RangePickerStore')
}
Символ здесь работает как контракт. Потребитель говорит «дай реализацию по токену RangePickerStore», а какой класс за ним прячется, знает только конфигурация контейнера. Сразу три плюса: типобезопасность (токен параметризуется интерфейсом), никаких коллизий имён и, самое вкусное, возможность зарегистрировать один класс под кучей разных токенов. К этому фокусу ещё вернёмся, потому что именно он оказался тем, ради чего всё и затевалось.
Как мы регистрируем сторы
Сборка контейнера живёт в отдельном модуле, который дёргается один раз при старте. Сначала поднимается сам контейнер, потом идут регистрации. У нас в ходу три способа, и каждый под свою задачу.
Самый простой способ: привязать токен к классу и попросить держать его в единственном экземпляре.
register<IRangePickerContainerStore>(STORE_TYPES.RangePickerStore)
.to(RangePickerStoreInjectable)
.inSingletonScope()
inSingletonScope означает «создай один раз, дальше отдавай тот же самый». Для по-настоящему общего добра, вроде справочников, роутера или глобального конфига, это самое то.
Иногда объект уже существует, и его надо просто занести в контейнер как константу. Так мы, например, регистрируем готовые глобальные объекты, чтобы код тянул их через DI единообразно со всем остальным, а не через прямой импорт синглтона.
register<IAppStore>(STORE_TYPES.AppStore).toConstantValue(AppStoreInstance)
И самое интересное: фабрики. Когда стору для рождения нужны другие сторы, мы регистрируем его через динамическое значение и достаём зависимости из контейнера прямо внутри:
register<IMainLayoutStore>(STORE_TYPES.MainLayoutStore)
.toDynamicValue(
(context) =>
new MainLayoutStore(
context.container.get(STORE_TYPES.RouterStore)
)
)
.inSingletonScope()
Вот тут инверсия зависимостей и показывает зубы. MainLayoutStore не импортирует RouterStore и вообще не в курсе, как тот создаётся. Он просто принимает готовую реализацию интерфейса роутера в конструктор. Всё связывание собрано в одном месте, в фабрике. Поменяется роутер завтра, потребители даже не вздрогнут.
Да, вся эта конфигурация выглядит как длинный список регистраций, и файл выходит немаленький. Но это принципиально другой «немаленький», чем у God-объекта. Здесь нет логики, только сухое «токен → как собрать». Такое читаешь сверху вниз, ищешь по токену за секунду и ревьюишь построчно без слёз. Сложность никуда не делась, она просто переехала из выполнения в конфигурацию, где с ней можно жить.
Один класс, много жизней
А теперь тот самый фокус, ради которого всё и затевалось. В консоли полно повторяющихся кусочков состояния: выбор мерчанта, диапазон дат, фильтр таблицы. Логика одна на всех, но на каждом экране нужен свой изолированный экземпляр.
С токенами это решается до неприличия элегантно. Мы пишем один класс RangePickerStoreInjectable, а потом регистрируем его столько раз, сколько независимых мест использования у нас есть, каждый раз под своим токеном:
register<IRangePickerContainerStore>(RangePickerSymbols.analytics)
.to(RangePickerStoreInjectable)
.inSingletonScope()
register<IRangePickerContainerStore>(RangePickerSymbols.monitoring)
.to(RangePickerStoreInjectable)
.inSingletonScope()
register<IRangePickerContainerStore>(RangePickerSymbols.invoices)
.to(RangePickerStoreInjectable)
.inSingletonScope()
Каждая регистрация даёт синглтон, но синглтон в рамках своего токена. Контейнер держит три независимых экземпляра одного класса, и состояние между ними не перетекает. Компонент из аналитики просит реализацию по токену analytics, компонент из мониторинга по monitoring, и они физически не могут наступить друг другу на ногу.
Это одним махом убирает целый класс багов, которые в наивной схеме лечатся копипастой полей и молитвой. Мы получаем переиспользование кода без переиспользования состояния, а это редкая штука: обычно приходится выбирать что-то одно. Один класс, единый источник логики. Много токенов, много отдельных, ни о чём не подозревающих друг о друге жизней этой логики. Когда впервые видишь это на реальном разделе, ощущение почти физическое. Именно ради этого паттерна вся затея с контейнером в консоли окупается в первую очередь.
MobX внутри injectable-стора
DI отвечает за то, кто кого создаёт и как долго тот живёт. А за реактивность, за то, чтобы интерфейс сам обновлялся при изменении данных, по-прежнему отвечает MobX. И живут они вместе прекрасно: контейнер собирает стор, а стор внутри себя объявляет наблюдаемое состояние.
Вот тот самый стор диапазона дат, который мы плодим под кучей токенов:
@injectable()
export class RangePickerStoreInjectable implements IRangePickerContainerStore {
period: PeriodType = null
startDate: Moment = null
endDate: Moment = null
constructor() {
makeObservable(this, {
period: observable,
startDate: observable,
endDate: observable,
setDates: action.bound,
setPeriod: action.bound,
clear: action
})
}
setDates(startDate: Moment, endDate: Moment) {
this.startDate = startDate
this.endDate = endDate
this.period = getPeriod(startDate, endDate)
}
setPeriod(period: PeriodType) {
const { startDate, endDate } = getDates(period)
this.startDate = startDate
this.endDate = endDate
this.period = period
}
clear = () => {
this.period = null
this.startDate = null
this.endDate = null
}
}
Обратите внимание на пару моментов. Декоратор @injectable() говорит: этим классом можно рулить через контейнер. makeObservable в конструкторе прямым текстом перечисляет, что наблюдаемое (observable), что действие (action), а что производное (computed). Мы сознательно выбрали явный makeObservable, а не «магический» makeAutoObservable. В больших сторах явное перечисление работает как документация и не даёт случайно превратить приватный хелпер в наблюдаемое поле, а это, поверьте, бывает больно.
Отдельно про action.bound. Методы стора рано или поздно улетают в компоненты как обработчики: в onChange, в onClick. Без привязки контекста такой метод, оторванный от объекта, теряет this. action.bound закрывает вопрос прямо на месте объявления: и действие MobX, и жёстко прибитый контекст. Мелочь, а сколько вечеров она экономит на отладке классического «почему у меня this внезапно undefined».
И главный вывод: класс не знает про DI ничего, кроме одного декоратора. Он не тащит контейнер, не лезет в глобальное состояние, не в курсе про соседей. Просто чистый носитель состояния и логики его изменения. Тестировать такие сторы одно удовольствие: создал напрямую, дёрнул метод, проверил поле.
Как это доезжает до компонентов
Собранное состояние надо ещё донести до React, и тут важно не растерять всю добытую развязку и не скатиться обратно в prop-drilling, когда сторы прокидываешь через десять уровней пропсов.
Компонент просит нужную реализацию по токену:
const rangePickerStore = subscribe<IRangePickerContainerStore>(
RangePickerSymbols.analytics
)
subscribe идёт в контейнер и отдаёт то, что зарегистрировано под токеном. Компонент не импортирует конкретный класс, не знает, синглтон это или нет, не участвует в сборке, он просто называет контракт. Чтобы всё это реактивно перерисовывалось, компонент оборачивается в observer из mobx-react, а дальше по накатанной: читаем поля в разметке, дёргаем методы-действия в обработчиках.
Разница с наивной схемой как небо и земля. Там компонент получал appStore целиком и через него дотягивался до нужного поля, попутно получая ключи от всей квартиры. Здесь он объявляет узкую, честную, типизированную зависимость от одного интерфейса. Открываешь компонент и сразу видишь, от какого состояния он зависит. Не из племенных знаний «что где лежит», а из кода.
Персистентность: коротко и осторожно
Часть состояния должна переживать перезагрузку страницы: язык интерфейса, кое-какие настройки. Для этого у нас mobx-persist поверх хранилища на IndexedDB через localForage. Помеченные поля сами сохраняются и при старте регидратируются, то есть восстанавливаются из хранилища.
И тут нужна дисциплина, иначе прилетит. Соблазн «запёрсистить побольше, на всякий случай» ведёт прямиком к тонким багам: после деплоя всплывают устаревшие данные, форматы разъезжаются, пользователь любуется состоянием недельной давности. Поэтому персистентного состояния у нас по минимуму, только то, что реально обязано пережить сессию. И очень внимательно относимся к моменту гидрации: асинхронное восстановление должно завершиться раньше, чем побежит зависящий от него код. Гонки на этом стыке дают самые мерзкие, «плавающие» баги, которые не воспроизводятся у тебя, но стабильно ломаются у клиента.
Без фанатизма
DI с самого начала не означает, что всё подряд надо заворачивать в injectable-сторы. Это ловушка в обратную сторону: можно так увлечься чистотой, что обернёшь фабрикой стор из двух полей просто ради принципа.
Мы держим баланс. Что-то по-настоящему глобальное и простое спокойно живёт как константа в контейнере или как один общий стор, и это осознанный выбор, а не недоработка. Граница проходит по здравому смыслу: под отдельный токен и свой жизненный цикл идёт то, что реально повторяется или должно быть изолировано по экранам. Всё остальное раздувать незачем. Контейнер для нас инструмент, а не религия, и относимся мы к нему соответственно.
И ещё одно правило, которое мы для себя усвоили: не гнаться за красивым, но недостижимым единообразием. Работающая прагматичная смесь лучше бесконечного рефакторинга ради чистоты, который никогда не доезжает до прода. Строго по канону выглядит эффектно на конференции, но продукт двигают релизами, а не каноном.
Грабли, которые мы собрали
Идеальных решений не бывает, у нашего тоже есть цена. Вот что стоило нам нервов.
Циклические зависимости. DI их не отменяет, но делает громкими. Если A через фабрику тянет B, а B тянет A, контейнер честно падает при разрешении. И это благо: цикл, который в God-объекте тихо гнил бы годами, тут вскрывается сразу. У нас так однажды всплыла связка стора фильтров и стора таблицы, которые незаметно тянули друг друга; в наивной схеме она жила бы вечно и стреляла бы в самый неподходящий момент. Лечится проектно: выделить общее в третий стор или получать зависимость лениво. Но требует думать про связи заранее, а не по факту.
Порядок инициализации. reflect-metadata первым, поднять контейнер до первого обращения к сторам, дождаться гидрации до старта зависящего кода. Всё это невидимые ниточки, и порвав любую, получаешь загадочное падение. Мы свели все эти шаги в один явный, задокументированный модуль запуска, чтобы порядок был на виду и никто его случайно не сдвинул.
Синглтоны, которые хотят стать богом. Синглтон удобен и потому опасен: общий на всё приложение стор легко превращается в маленький God-объект, накапливая ссылки и состояние, которым место в другом месте. Правило, которое мы для себя вывели: синглтон делаем только осознанно, а не по умолчанию. Всё, что по смыслу привязано к экрану, регистрируется под своим токеном и/или явно сбрасывается на выходе со страницы.
Декораторы против сборщика. Метаданные декораторов и агрессивный tree-shaking иногда воюют: сборщик норовит «оптимизировать» класс, который нигде явно не импортируется, кроме конфигурации контейнера. Пришлось следить, чтобы регистрации реально доезжали до контейнера, и не полагаться на неявные побочки импортов.
Кривая входа. Контейнер, символы, фабрики, реактивность: за всё это платишь временем на онбординг. Новичку мало сказать «где что лежит», надо объяснить «почему устроено так». Мы закрыли это документацией паттернов и живыми примерами: как завести новый стор, как повесить его на несколько токенов, как подписаться в компоненте. Один раз пройдя маршрут, люди начинают летать быстрее, чем в наивной модели. Но первый раз их приходится вести за руку.
Что в итоге
Ставка на IoC/DI поверх MobX с самого начала дала ровно то, ради чего мы её делали. Сторы маленькие, изолированные, тестируемые. Зависимости честные: смотришь на класс или компонент и видишь, что ему на самом деле нужно. Переиспользование состояния не головная боль: один класс, много токенов, никакого смешения экземпляров. Жизненный цикл под контролем: мы сами решаем, что живёт вечно, а что рождается заново. И, что важно, всё это далось без большого разворота: мы не платили за миграцию и не морозили релизы ради переустройства, потому что заложили основу сразу.
Только это не серебряная пуля. Цена есть: лишний слой абстракции, хрупкость на стыке декораторов и сборки, более крутой онбординг. Для приложения на десяток экранов вся эта машинерия это стрельба из пушки по воробьям, там честный корневой стор проще и понятнее, и тащить в него контейнер ради галочки мы бы не советовали. IoC окупается там, где сложность настоящая и продолжает расти, где над кодом работает несколько команд и где состояние и есть центральная сущность продукта. Наша консоль как раз такой случай, и, судя по тому, как спокойно она растёт, ставка себя оправдала.
Если бы начинали заново, мы бы поменяли порядок двух вещей. Во-первых, договорились бы про конвенции по токенам и жизненному циклу ещё жёстче с самого старта: единая схема именования символов и правило «синглтон только осознанно» сэкономили бы пару болезненных мест. Во-вторых, раньше зафиксировали бы и записали инфраструктурную настройку сборки, чтобы новенькие не теряли дни на «магические» ошибки разрешения зависимостей.
А главный вывод даже не про библиотеки. Архитектура большого фронтенда вообще не про то, какую «правильную» стейт-менеджмент-библиотеку выбрать. Это про управление связями и жизнью объектов. MobX отлично делает состояние реактивным, но сам по себе молчит на вопрос «кто кого создаёт, кто от кого зависит и как долго живёт». На этот вопрос отвечает IoC. Сложность приложения при этом не исчезает, её вообще нельзя убить. Но её можно перетащить туда, где ею удобно рулить: из мутных связей в выполнении в явную, читаемую, ревьюабельную конфигурацию. И умение делать сложность видимой и управляемой и есть взрослая фронтенд-архитектура.