MobX
MobX takes a different philosophy from Redux: instead of immutable snapshots and reducers, you keep plain mutable objects and let MobX track which observable values each component reads. When you change a value, only the components that actually used it re-render — automatically, with no selectors or dependency arrays. The model is “transparent functional reactive programming”: anything that can be derived from your state is derived for you, and kept consistent without manual wiring.
The mental model
MobX has three core concepts. Observables are the state — fields whose reads and writes MobX intercepts. Computeds are values derived from observables; they cache automatically and only recompute when their inputs change. Actions are the functions that mutate observables. React components become reactive by wrapping them in observer, which subscribes the component to exactly the observables it touched during render.
The big contrast with Redux: in Redux you must return new immutable objects from reducers and select slices to avoid extra renders. In MobX you mutate state directly (this.count++) and MobX figures out the rest. It is closer to Vue’s reactivity than to Redux’s discipline.
Installation
Install the core library plus the React bindings:
npm install mobx mobx-react-lite
mobx-react-lite is the modern, hooks-only binding for function components. (The older mobx-react adds class-component support you rarely need today.)
Creating an observable store
makeAutoObservable is the simplest way to define a store: it inspects your object and automatically marks fields as observable, methods as actions, and getters as computeds.
// stores/CartStore.js
import { makeAutoObservable } from 'mobx';
class CartStore {
items = [];
constructor() {
makeAutoObservable(this);
}
// action — mutates observable state directly
addItem(product) {
const existing = this.items.find((i) => i.id === product.id);
if (existing) {
existing.qty += 1;
} else {
this.items.push({ ...product, qty: 1 });
}
}
removeItem(id) {
this.items = this.items.filter((i) => i.id !== id);
}
// computed — cached, recomputes only when items change
get count() {
return this.items.reduce((sum, i) => sum + i.qty, 0);
}
get total() {
return this.items.reduce((sum, i) => sum + i.qty * i.price, 0);
}
}
export const cartStore = new CartStore();
Notice we mutate directly: existing.qty += 1 and this.items.push(...). With Redux that would be a bug; in MobX it is the intended API. The count and total getters are computeds — reading them is cheap because MobX caches the result until items actually changes.
Reading state in components with observer
Wrap any component that reads observables in observer. That is the only step needed to make it reactive — no useSelector, no connect, no provider.
// Cart.jsx
import { observer } from 'mobx-react-lite';
import { cartStore } from './stores/CartStore';
const Cart = observer(function Cart() {
return (
<section>
<h2>Cart ({cartStore.count} items)</h2>
<ul>
{cartStore.items.map((item) => (
<li key={item.id}>
{item.title} × {item.qty}
<button onClick={() => cartStore.removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
<strong>Total: ${cartStore.total.toFixed(2)}</strong>
</section>
);
});
export default Cart;
Because this component read cartStore.count, cartStore.items, and cartStore.total, MobX subscribes it to exactly those. A component elsewhere that reads only cartStore.count will not re-render when you change an unrelated observable.
// CartBadge.jsx — re-renders only when count changes
import { observer } from 'mobx-react-lite';
import { cartStore } from './stores/CartStore';
const CartBadge = observer(() => <span className="badge">{cartStore.count}</span>);
export default CartBadge;
Forgetting
observeris the number-one MobX gotcha. A component that reads observables but is not wrapped will render once and then silently stop updating. If a value looks frozen, check that the component is anobserver.
Async actions
Async work needs a little care: any code that mutates observables after an await runs outside the original action, so MobX warns about it in strict mode. Wrap the post-await mutation in runInAction (or mark it with flow).
// stores/ProductStore.js
import { makeAutoObservable, runInAction } from 'mobx';
class ProductStore {
products = [];
loading = false;
constructor() {
makeAutoObservable(this);
}
async load() {
this.loading = true;
const res = await fetch('https://fakestoreapi.com/products?limit=3');
const data = await res.json();
runInAction(() => {
this.products = data;
this.loading = false;
});
}
}
export const productStore = new ProductStore();
Reacting outside React
autorun runs a function immediately and re-runs it whenever any observable it read changes — handy for logging, persistence, or syncing to localStorage without a component.
import { autorun } from 'mobx';
import { cartStore } from './stores/CartStore';
autorun(() => {
console.log(`Cart now has ${cartStore.count} items, total $${cartStore.total}`);
});
cartStore.addItem({ id: 1, title: 'Keyboard', price: 49.99 });
cartStore.addItem({ id: 1, title: 'Keyboard', price: 49.99 });
Output:
Cart now has 0 items, total $0
Cart now has 1 items, total $49.99
Cart now has 2 items, total $99.98
MobX vs Redux at a glance
| Aspect | MobX | Redux Toolkit |
|---|---|---|
| State shape | Mutable observable objects | Immutable plain objects |
| Updates | Mutate directly inside actions | Return new state (Immer drafts) |
| Re-render control | Automatic, per-observable tracking | Manual via selectors |
| Derived values | computed getters (auto-cached) | createSelector memoization |
| Boilerplate | Very low | Moderate |
| Time-travel debugging | Limited | First-class via DevTools |
Best Practices
- Always wrap components that read observables in
observer; this is what makes them reactive. - Use
makeAutoObservablefor stores so you do not have to annotate every field, getter, and method by hand. - Express anything derivable as a
computedgetter rather than recomputing it in render — it caches and stays consistent. - Mutate observables only inside actions; wrap post-
awaitmutations inrunInActionto satisfy strict mode. - Keep observable objects passed into
observercomponents small and specific so MobX’s tracking can be precise. - Prefer one store per domain (cart, products, user) over a single mega-store; instantiate them once at module scope.
- Read observables inside the
observer’s render body, not in a parent that forwards them as props, or you lose fine-grained tracking.