mobx
MobX is a framework-independent state manager built around mutable observable objects. Code reads ordinary properties inside tracked functions, MobX records those reads, and later writes invalidate only the computed values, reactions, or UI components that used them. Classes, objects, arrays, maps, sets, computed getters, actions, and cancellable generator-based async flows are supported. React needs the separate mobx-react-lite or mobx-react binding. Version 7 targets modern JavaScript engines with native Proxy and Stage 3 decorators, although decorators remain optional.
MobX remains excellent for rich mutable domain models and fine-grained UI updates, especially when state must live outside React. Skip it for simple shared state or server-cache problems, and budget time for its runtime tracking model and reaction lifecycle.
Use it if
- Your domain model is naturally expressed as long-lived classes and mutable objects with computed properties
- You want fine-grained updates without writing selectors, reducers, immutable update code, or dependency arrays by hand
- The same state and business rules must run outside React in Node, tests, workers, or another view layer
- Your team is willing to learn observable reads, actions, derivations, and reaction disposal as architectural concepts
- You must support Internet Explorer or an older JavaScript runtime without native Proxy; MobX 7 removed its ES5 and non-Proxy fallback
- You only need server-data fetching, caching, retries, and request deduplication; MobX does not provide those policies, so TanStack Query or a framework data layer fits the problem better
- You want state transitions represented as plain immutable events for replay, serializable debugging, or audit logs; MobX centers mutable objects and runtime dependency tracking
- A 14.7 KB gzipped core is too much for a small interface; lighter stores such as Zustand or Valtio can cover simple shared state with less machinery
- Your codebase depends on MobX 6 compatibility APIs: v7 removed legacy decorators, trace, non-Proxy options, namespaced annotation helpers, and React Provider/inject APIs
Setup reality
Installing mobx alone gives the framework-independent core with TypeScript declarations and no runtime dependencies, native addons, credentials, or required config file. Browser and Node targets must provide native Proxy; use MobX 6 if they do not. React is a separate install: mobx-react-lite handles function components, while mobx-react adds class components, and the v7-compatible binding releases require React 18 or later. Wrap every component that reads observables in observer and read the properties during render; a value destructured earlier or read later in a timeout is outside the tracked function and will not trigger updates. For classes, makeAutoObservable is the low-ceremony path. If you choose Stage 3 decorators, use TypeScript 5+ with experimentalDecorators removed or disabled, add accessor to observable fields, and do not call makeObservable for decorated fields. Non-decorator class builds should use spec-compliant field emit, including useDefineForClassFields: true in TypeScript. Decide on configure rules early: enforceActions defaults to observed, while always is stricter and often noisy in tests. Every autorun, reaction, and observer created manually holds references until disposed, so return cleanup functions from components and services. After each await, mutations need runInAction, an action callback, or flow. For server rendering, call enableStaticRendering(true) from the React binding to prevent observers from waiting forever for updates. The difficult setup is not installation; it is keeping reads inside tracked scopes, writes inside actions, and reaction ownership explicit.
Patterns
Require writes to happen inside actionsconfigure-actions
import {configure} from 'mobx';
configure({
enforceActions: 'always',
computedRequiresReaction: true,
reactionRequiresObservable: true
});The default enforceActions mode is observed. The always setting catches more mistakes but also requires action context during lazy creation and can be irritating in unit tests.
Create a class store with automatic annotationscreate-class-store
import {makeAutoObservable} from 'mobx';
class CartStore {
items = [];
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
add(item) {
this.items.push(item);
}
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
export const cart = new CartStore();makeAutoObservable infers fields as observable, getters as computed, and methods as actions. It cannot be used on classes with super or subclasses; use makeObservable there.
Control annotations with makeObservableannotate-store-explicitly
import {actionBound, computed, makeObservable, observable, observableRef} from 'mobx';
class SearchStore {
query = '';
results = [];
constructor() {
makeObservable(this, {
query: observable,
results: observableRef,
resultCount: computed,
setQuery: actionBound
});
}
get resultCount() { return this.results.length; }
setQuery(value) { this.query = value; }
}MobX 7 uses named exports such as observableRef and actionBound. The MobX 6 forms observable.ref and action.bound were removed.
Make a plain object observablecreate-observable-object
import {observable, runInAction} from 'mobx';
const session = observable({
user: null,
preferences: {theme: 'system'}
});
runInAction(() => {
session.user = {id: 'u1', name: 'Ada'};
session.preferences.theme = 'dark';
});Observable objects are deep by default and Proxy-backed in v7. Use observableRef when a field should track replacement without converting its assigned value.
Re-enter an action after awaitingupdate-after-await
import {runInAction} from 'mobx';
async function loadUser(store, id) {
store.loading = true;
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
runInAction(() => { store.user = user; });
} finally {
runInAction(() => { store.loading = false; });
}
}An action only covers the current synchronous call stack. Code after await needs runInAction, another action, or a MobX flow.
Model cancellable async work with flowrun-async-flow
import {flow, observable} from 'mobx';
const state = observable({projects: [], loading: false});
const loadProjects = flow(function* () {
state.loading = true;
try {
const response = yield fetch('/api/projects');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
state.projects = yield response.json();
} finally {
state.loading = false;
}
});
const task = loadProjects();
// task.cancel();flow keeps each generator continuation in action context and returns a cancellable promise. Cancellation is cooperative and still runs finally blocks.
Run and dispose an effectautorun-effect
import {autorun} from 'mobx';
const dispose = autorun(() => {
localStorage.setItem('theme', store.preferences.theme);
});
// when the owner is destroyed
dispose();autorun executes immediately and tracks observable reads made during that synchronous execution. Reads inside a promise or timer callback are not tracked, and undisposed reactions can leak.
React only when a selected value changesreaction-specific-value
import {reaction} from 'mobx';
const dispose = reaction(
() => store.search.query.trim(),
(query, previousQuery) => {
analytics.track('search_changed', {query, previousQuery});
},
{fireImmediately: false}
);Unlike autorun, reaction tracks only the first function and does not run the effect initially unless fireImmediately is true. Dispose it with its owning service or component.
Wait until observable state matches a conditionwait-for-state
import {when} from 'mobx';
await when(
() => session.status === 'authenticated',
{timeout: 10_000}
);
startAuthenticatedWork();The promise rejects on timeout. If the caller can be abandoned earlier, use the AbortSignal option or the effect form of when so you can dispose it explicitly.
Use an observable map for keyed recordstrack-dynamic-keys
import {autorun, observable, runInAction} from 'mobx';
const users = observable.map();
const dispose = autorun(() => {
console.log(users.get('u1')?.name ?? 'missing');
});
runInAction(() => {
users.set('u1', {name: 'Ada'});
});Observable maps can track a key before it exists, which makes them a clear fit for dynamic dictionaries. Remember to dispose the autorun.
Render observable state in Reactrender-react-store
import {observer} from 'mobx-react-lite';
export const CartSummary = observer(function CartSummary({cart}) {
return (
<button onClick={() => cart.clear()}>
{cart.items.length} items, {cart.total.toFixed(2)}
</button>
);
});Install mobx-react-lite separately for function components. Read observable properties during the observer render; passing an already extracted plain value loses that dependency.
Inspect what a reaction actually tracksdebug-dependencies
import {autorun, getDependencyTree} from 'mobx';
const dispose = autorun(() => {
console.log(store.currentUser.name);
});
console.dir(getDependencyTree(dispose), {depth: null});
dispose();MobX 7 removed trace. Use getDependencyTree, getObserverTree, spy, or developer tools when an update runs too often or not at all.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zustand | npm | You want a smaller hook-oriented React store with explicit selectors and little ceremony |
| @reduxjs/toolkit | npm | You value explicit events, immutable state, serializable tooling, and a widely standardized team architecture |
| xstate | npm | Your hard problem is modeling legal states, events, guards, and workflows rather than general object reactivity |
| valtio | npm | You like proxy-based mutable state but want a smaller, React-focused API |