mobx review
MobX 7.0.3 is a renderer-independent state library that tracks ordinary property reads and writes at runtime. You mark objects, class fields, getters, arrays, maps, or sets as observable; computed values cache derived work, actions group mutations, and reactions rerun only after a value they read changes. React rendering comes from the separate `mobx-react-lite` or `mobx-react` package. Version 7 removes the non-Proxy fallback, legacy decorators, `trace()`, and several namespaced annotations. Patch 7.0.3 adds a Node export condition so ESM imports and CommonJS requires share one MobX instance and avoid repeated `NODE_ENV` checks.
MobX 7.0.3 installed in 0.9 seconds with 0 dependencies and 0 audit findings, but its core added 15.2 KB gzipped in our browser build; it earns that cost in applications with rich mutable domain models and many fine-grained derivations. Choose a smaller store for simple shared UI state, and use a query library when the difficult part is remote caching.
We installed it
| Install | ✓ · 0.9s | 1 package on disk · 5 MB |
| Import | ✓ | ESM import works · require() works · CommonJS package with exports map |
| Browser | 15.2 KB | gzipped (52.4 KB minified), bundled with esbuild |
| Types | ✓ | TypeScript types bundled |
| Known vulns | 0 | 0 critical · 0 high · 0 moderate · 0 low (npm audit) |
Answers from our run
Does mobx install cleanly?
Yes. In a fresh container with an empty cache, npm install mobx finished in 0.9s, leaving 1 package and 5 MB on disk. npm audit reported no known vulnerabilities.
How much does mobx add to a browser bundle?
15.2 KB gzipped (52.4 KB minified) when the whole package is bundled for the browser with esbuild. Importing only part of it is usually smaller.
Does mobx work with both ESM and CommonJS?
Yes. Both import 'mobx' and require('mobx') worked in Node 22 in our run. The package is published as CommonJS with an exports map.
Does mobx include TypeScript types?
Yes, type declarations ship inside the package, so no @types install is needed.
mobx or zustand: which should you use?
zustand: Use it for a compact React store with explicit selectors and a smaller conceptual surface. MobX 7.0.3 installed in 0.9 seconds with 0 dependencies and 0 audit findings, but its core added 15.2 KB gzipped in our browser build; it earns that cost in applications with rich mutable domain models and many fine-grained derivations.
When should you not use mobx?
Any supported runtime lacks native Proxy. MobX 7 removed the ES5 fallback and the useProxies configuration.
Use it if
- The domain is naturally modeled as long-lived mutable classes with getters for derived values.
- Fine-grained updates should follow property reads without handwritten selectors or immutable reducer code.
- One store must run in React, Node services, tests, workers, or another view layer.
- The team can make reaction ownership, action boundaries, and tracked read locations explicit.
- Any supported runtime lacks native Proxy. MobX 7 removed the ES5 fallback and the `useProxies` configuration.
- The main need is remote-data caching, request deduplication, retry policy, or stale-time handling. MobX supplies reactivity, not a server-state client.
- Every change must be a serializable event for replay, audit, or deterministic reducer tooling. MobX's default model is direct mutation inside actions.
- A small widget cannot justify our measured 15.2 KB gzipped browser bundle for the core before adding a React binding.
- The codebase still uses `observable.ref`, `action.bound`, legacy decorators, `trace()`, `Provider`, or `inject`. MobX 7 replaces or removes those APIs.
- Developers are unlikely to dispose reactions consistently. An `autorun()` or `reaction()` can retain its owner through observed objects until its disposer runs.
Setup reality
We installed MobX 7.0.3 in a fresh Node 22 Bookworm sandbox in 0.9 seconds. It left one package and 5 MB on disk; the package itself was 5,064 KB unpacked. npm audit reported 0 known vulnerabilities. MobX has 0 direct and 0 peer dependencies, an MIT license, and bundled TypeScript declarations. It is published as CommonJS with an exports map, and both require() and ESM import worked in our checks.
There are no credentials, native builds, or required config files. Native Proxy support is mandatory in version 7. React users install a binding separately: mobx-react-lite covers function components, while mobx-react also covers classes. The version 7 bindings require React 18 or newer. Every component that reads observables must be wrapped in observer, and the read must happen during render. Passing a plain value extracted earlier gives MobX nothing to track.
For classes, makeAutoObservable() infers fields, getters, methods, and generators, but it cannot handle a class with super or a subclass. Use makeObservable() and current named annotations such as observableRef or actionBound there. Stage 3 decorators require TypeScript 5 or a matching Babel setup, accessor on observable fields, and no makeObservable(this) for those decorated members. Set action enforcement once near application startup rather than changing rules between modules.
Tracking covers synchronous reads only. Code after await runs outside the original action, so update through runInAction(), another action, or flow(). autorun(), reaction(), and the effect form of when() return disposers; attach each one to a service or component lifecycle. For server rendering, enable static rendering in the React binding. Our browser import measured 52.4 KB minified and 15.2 KB gzipped. Version 7.0.3's new Node condition also prevents mixed import and require paths from constructing separate MobX instances.
Patterns
Require state writes inside actions configure-actions
import { configure } from 'mobx'
configure({
enforceActions: 'always',
computedRequiresReaction: true,
reactionRequiresObservable: true
})MobX defaults `enforceActions` to `observed`. The stricter setting catches stray writes but may need deliberate exceptions in tests and lazy setup.
Infer annotations for a class store create-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()Fields become observable, getters computed, and methods actions. Classes with a superclass or subclasses need explicit `makeObservable()` annotations.
Choose exact class annotations annotate-store
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 older namespaced forms were removed.
Declare a Stage 3 decorated model use-modern-decorators
import { action, computed, observable } from 'mobx'
class Counter {
@observable accessor value = 0
@computed
get doubled() { return this.value * 2 }
@action
increment() { this.value += 1 }
}Use TypeScript 5 or current Babel decorator support, add `accessor`, and remove `experimentalDecorators` plus `makeObservable(this)` for these fields.
Track a plain object deeply create-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()` clones a plain object into a Proxy and converts nested plain values. Class instances assigned later are not converted automatically.
Resume mutation after an await update-after-await
import { runInAction } from 'mobx'
async function loadUser(store, id) {
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 })
}An action covers one synchronous stack. Code after `await` needs a new action context when action enforcement is enabled.
Keep generator steps inside actions run-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')
state.projects = yield response.json()
} finally {
state.loading = false
}
})
const task = loadProjects()
// task.cancel()`flow()` wraps each resumed generator step as an action and returns a cancellable promise. Its `finally` block still runs on cancellation.
Own and dispose an autorun autorun-effect
import { autorun } from 'mobx'
const dispose = autorun(() => {
localStorage.setItem('theme', store.preferences.theme)
})
// when this service is destroyed
dispose()The effect runs immediately and tracks synchronous observable reads. Reads inside a later timer or promise are outside the tracked pass.
React to one selected value reaction-value
import { reaction } from 'mobx'
const dispose = reaction(
() => store.search.query.trim(),
(query, previousQuery) => {
analytics.track('search_changed', { query, previousQuery })
}
)Only the data function is tracked. The effect waits for a change by default, unlike `autorun()`, and the returned disposer must follow the owner's lifecycle.
Wait for observable state once wait-for-condition
import { when } from 'mobx'
await when(
() => session.status === 'authenticated',
{ timeout: 10_000 }
)
startAuthenticatedWork()The promise rejects after the 10-second timeout. The promise returned by `when()` also has a `cancel()` method.
Store records in an observable map track-dynamic-map
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' }))Maps track a missing key before it exists and avoid the descriptor-cache cost of repeatedly adding unstable property names to plain objects.
Read observables in a React render render-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. Read observable properties inside the observed render instead of passing values that were extracted earlier.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| zustand | npm | Use it for a compact React store with explicit selectors and a smaller conceptual surface. |
| @reduxjs/toolkit | npm | Use it when explicit actions, immutable updates, serializable debugging, and a standard team structure matter. |
| xstate | npm | Use it when legal states, events, guards, and workflow transitions are harder than general object reactivity. |
| valtio | npm | Use it for proxy-backed mutable state with a lighter, React-oriented API. |
More web frontend guides
postcss · react · react-dom · tailwindcss · htmlparser2 · tailwind-merge · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

