MobX for React State: Observables, Computed Values, Actions, Reactions and Multiple Stores
Key takeaways
MobX is a simple, scalable state management solution that makes state management transparent through reactive programming. It automatically tracks dependencies and updates components.
Introduction
MobX is a battle-tested library that makes state management simple and scalable by transparently applying functional reactive programming (FRP). The philosophy is simple: anything that can be derived from the application state, should be derived automatically.
The Problem
Traditional state management:
// Redux: Too much boilerplate
const reducer = (state, action) => {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
default:
return state;
}
};
// Context: Re-renders entire tree
const [state, setState] = useState({ count: 0, name: '' });
// Changing count re-renders all consumers
The Solution
With MobX:
import { makeObservable, observable, action } from 'mobx';
class Store {
count = 0;
constructor() {
makeObservable(this, {
count: observable,
increment: action,
});
}
increment() {
this.count++;
}
}
How MobX Works, and What It Trades Away
MobX wraps your state in observable objects (implemented with JavaScript Proxies for plain objects and arrays, and property getters/setters on classes). Whenever a derivation — an observer component’s render, a computed getter, an autorun — reads an observable, MobX records that dependency. When the observable changes inside an action, MobX re-runs exactly the derivations that read it. You never list dependencies by hand, as you would with selectors in Redux or dependency arrays in useEffect.
That automatic tracking is the whole appeal, and also the source of MobX’s characteristic bugs. Dependencies are collected only for observables that are actually read during the derivation, and only synchronously. If a component reads store.user.name inside a setTimeout callback, or destructures a value outside an observer, MobX never sees the read and the UI does not update. The rest of this guide points out those places. The other trade-off is style: MobX encourages mutable, class-based stores (this.count++), which feels natural to developers from OOP backgrounds and foreign to teams used to Redux’s immutable updates, time-travel debugging, and explicit action logs. MobX 6 dropped the requirement for decorators — makeObservable and makeAutoObservable work in plain JavaScript — which removed much of the build-configuration friction older versions had.
Installation
npm install mobx mobx-react-lite
Core Concepts
Observable State
import { makeObservable, observable, action } from 'mobx';
class TodoStore {
todos = [];
constructor() {
makeObservable(this, {
todos: observable,
addTodo: action,
});
}
addTodo(text) {
this.todos.push({ id: Date.now(), text, done: false });
}
}
With makeAutoObservable (Simpler)
import { makeAutoObservable } from 'mobx';
class TodoStore {
todos = [];
constructor() {
makeAutoObservable(this); // Automatically marks everything
}
addTodo(text) {
this.todos.push({ id: Date.now(), text, done: false });
}
get completedCount() {
return this.todos.filter(t => t.done).length;
}
}
makeAutoObservable infers annotations from the shape of the class: fields become observable, getters become computed, methods become action, and generator methods become flow. It must be called in the constructor after all fields are initialized, and it does not work on classes that use super or are subclassed (use makeObservable with explicit annotations there). A common TypeScript gotcha: with useDefineForClassFields (the default when targeting ES2022+), a field declared without an initializer (todos: Todo[];) may not exist yet when makeAutoObservable runs, so it is never made observable. Initialize every field (todos: Todo[] = []).
Observables are deep by default: pushing a plain object into todos makes that object observable too, which is why toggling todo.done later triggers updates. For large read-only data (a 10,000-row API response you only display), observable.ref or observable.shallow avoids the cost of converting every nested object.
React Integration
observer Component
import { observer } from 'mobx-react-lite';
import { todoStore } from './stores';
const TodoList = observer(() => {
return (
<div>
{todoStore.todos.map(todo => (
<div key={todo.id}>{todo.text}</div>
))}
</div>
);
});
observer turns the component’s render into a tracked derivation: during each render it records which observables were read (todoStore.todos, its length, and each todo.text) and re-renders the component when any of them change. Forgetting observer is the number-one MobX bug — the component renders correctly the first time and then never updates, with no error or warning, because nothing is subscribed. Only components that read observables need it, but wrapping every component that touches store data is the simplest reliable rule.
Tracking happens only during render. Values read in event handlers or effects are not tracked, which is usually what you want; but a value read inside a render-prop callback that a non-observer child calls later (for example, the row renderer of a virtualized list library) escapes tracking, and the fix is to wrap that callback’s output in <Observer>{() => ...}</Observer>.
Creating Store Context
// stores/TodoStore.ts
import { makeAutoObservable } from 'mobx';
class TodoStore {
todos = [];
constructor() {
makeAutoObservable(this);
}
addTodo(text: string) {
this.todos.push({ id: Date.now(), text, done: false });
}
toggleTodo(id: number) {
const todo = this.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}
}
export const todoStore = new TodoStore();
// App.tsx
import { useState } from 'react';
import { observer } from 'mobx-react-lite';
import { todoStore } from './stores/TodoStore';
const App = observer(() => {
const [text, setText] = useState('');
const handleAdd = () => {
todoStore.addTodo(text);
setText('');
};
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={handleAdd}>Add</button>
<ul>
{todoStore.todos.map(todo => (
<li key={todo.id} onClick={() => todoStore.toggleTodo(todo.id)}>
{todo.done ? '✅' : '⬜'} {todo.text}
</li>
))}
</ul>
</div>
);
});
Local UI state (the input text) stays in useState; shared domain state (the todo list) lives in the store. Mixing the two is normal in MobX apps, and putting every keystroke of every form into global stores is a common over-engineering mistake. The module-level todoStore singleton is the simplest setup and fine for client-only apps, but with server-side rendering a singleton is shared across all requests on the server, leaking one user’s state into another user’s page. For SSR, create stores per request and provide them through React context, as section 8 does.
Computed Values
import { makeAutoObservable, computed } from 'mobx';
class CartStore {
items = [];
constructor() {
makeAutoObservable(this);
}
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
get itemCount() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
}
Computed values are cached — while something observes them:
import { autorun, runInAction } from 'mobx';
const cart = new CartStore();
runInAction(() => cart.items.push({ price: 10, quantity: 2 }));
const dispose = autorun(() => console.log(cart.total)); // Computes: 20
console.log(cart.total); // Cached: 20 (doesn't recompute)
runInAction(() => { cart.items[0].quantity = 3; }); // Recomputes: 30
dispose();
The caching rule is the part most tutorials get wrong. A computed value is memoized only while it is being observed — by an observer component, an autorun, a reaction, or another observed computed. Reading cart.total from plain code with no active observer recomputes it on every access, and MobX discards the cached value as soon as nothing observes it anymore. In a React app this rarely matters, because computeds are read from observer components. It matters in scripts and tests, and when an expensive computed is read in a loop outside a reaction; computed(..., { keepAlive: true }) keeps it cached at the cost of never releasing it.
Computed getters should be pure: no side effects, no mutations, no network requests. They can be evaluated lazily, more than once, or not at all, and MobX throws if a computed modifies observable state. The mutations above are wrapped in runInAction because with the default enforceActions: "observed" setting, MobX warns when observed state changes outside an action.
Actions
Synchronous Actions
import { makeAutoObservable, action } from 'mobx';
class UserStore {
users = [];
constructor() {
makeAutoObservable(this);
}
addUser(user) {
this.users.push(user);
}
removeUser(id) {
this.users = this.users.filter(u => u.id !== id);
}
}
An action does two things. It marks the code as an intentional state change, which the enforceActions check relies on, and it batches updates: all mutations inside one action are applied, and derivations re-run once when the outermost action ends, instead of after every single assignment. Without batching, a method that updates five fields would re-render dependent components five times, sometimes with inconsistent intermediate state.
One subtlety with makeAutoObservable: methods become actions bound to the prototype, but not bound to the instance. Passing store.addUser as a callback (onClick={store.addUser}) loses this. Use makeAutoObservable(this, {}, { autoBind: true }) or arrow functions in the call site.
Async Actions (runInAction)
import { makeAutoObservable, runInAction } from 'mobx';
class UserStore {
users = [];
loading = false;
constructor() {
makeAutoObservable(this);
}
async fetchUsers() {
this.loading = true;
try {
const res = await fetch('/api/users');
const users = await res.json();
runInAction(() => {
this.users = users;
this.loading = false;
});
} catch (error) {
runInAction(() => {
this.loading = false;
});
}
}
}
flow (Alternative for Async)
import { makeAutoObservable, flow } from 'mobx';
class UserStore {
users = [];
loading = false;
constructor() {
makeAutoObservable(this, {
fetchUsers: flow,
});
}
*fetchUsers() {
this.loading = true;
try {
const res = yield fetch('/api/users');
const users = yield res.json();
this.users = users;
} finally {
this.loading = false;
}
}
}
Both versions exist because an action only covers the synchronous part of a function (see the FAQ). runInAction wraps each post-await block explicitly. flow uses a generator instead: every yield is a suspension point, and MobX wraps each resumed segment in an action automatically, so the code reads like async/await without the wrappers. flow also returns a cancellable promise (const p = store.fetchUsers(); p.cancel();), which is handy when a user navigates away before a request completes. In TypeScript, yield expressions are typed as any; the flowResult() helper restores the proper return type when calling the flow.
Both examples still have a race: if fetchUsers is called twice quickly, the slower first response can arrive last and overwrite the newer data. Track a request counter or use cancellation when that matters.
Reactions
autorun
import { autorun } from 'mobx';
const store = new TodoStore();
autorun(() => {
console.log('Total todos:', store.todos.length);
});
store.addTodo('Learn MobX'); // Logs: Total todos: 1
reaction
import { reaction } from 'mobx';
reaction(
() => store.todos.length, // What to track
(count) => {
console.log('Todo count changed:', count);
}
);
when
import { when } from 'mobx';
when(
() => store.todos.length > 5,
() => console.log('More than 5 todos!')
);
The three differ in what they track. autorun runs immediately and re-runs whenever anything it read changes — simple, but it is easy to read an extra observable in a log statement and trigger far more runs than intended. reaction splits tracking from effect: only the first function (the data function) is tracked, the effect receives its result and runs only when that result changes, and it does not run initially unless you pass { fireImmediately: true }. That makes reaction the right tool for side effects such as saving to localStorage or syncing to a URL. when runs its effect once, the first time the condition becomes true, and then disposes itself; without the second argument it returns a promise you can await.
Every reaction returns a disposer function, and forgetting to call it is MobX’s classic memory leak: a reaction created in a component or a short-lived object keeps running (and keeps the object alive) after it is gone. In React, create reactions in useEffect and return the disposer as the cleanup; in stores, keep the disposers and call them in a dispose() method.
Real-World Example: Shopping Cart
// stores/CartStore.ts
import { makeAutoObservable } from 'mobx';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
class CartStore {
items: CartItem[] = [];
constructor() {
makeAutoObservable(this);
}
addItem(product: { id: number; name: string; price: number }) {
const existing = this.items.find(item => item.id === product.id);
if (existing) {
existing.quantity++;
} else {
this.items.push({ ...product, quantity: 1 });
}
}
removeItem(id: number) {
this.items = this.items.filter(item => item.id !== id);
}
updateQuantity(id: number, quantity: number) {
const item = this.items.find(item => item.id === id);
if (item) {
item.quantity = quantity;
}
}
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
get itemCount() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
clear() {
this.items = [];
}
}
export const cartStore = new CartStore();
// components/Cart.tsx
import { observer } from 'mobx-react-lite';
import { cartStore } from '../stores/CartStore';
export const Cart = observer(() => {
return (
<div>
<h2>Shopping Cart ({cartStore.itemCount} items)</h2>
{cartStore.items.map(item => (
<div key={item.id}>
<span>{item.name}</span>
<span>${item.price}</span>
<input
type="number"
value={item.quantity}
onChange={e => cartStore.updateQuantity(item.id, +e.target.value)}
/>
<button onClick={() => cartStore.removeItem(item.id)}>Remove</button>
</div>
))}
<div>Total: ${cartStore.total.toFixed(2)}</div>
<button onClick={() => cartStore.clear()}>Clear Cart</button>
</div>
);
});
addItem mutates the existing item’s quantity in place instead of replacing the array, and MobX notices because existing is an observable object inside an observable array. The component reads itemCount, items, and each item’s fields, so changing one quantity re-runs total and itemCount and re-renders the cart. In a real app, updateQuantity should validate its input: +e.target.value is 0 for an empty field and NaN for invalid text, which would make total display NaN. Removing the item when the quantity reaches zero, and storing prices as integer cents to avoid floating-point sums like 0.1 + 0.2, are the usual next steps.
Multiple Stores
// stores/RootStore.ts
import { makeAutoObservable } from 'mobx';
import { UserStore } from './UserStore';
import { CartStore } from './CartStore';
class RootStore {
userStore: UserStore;
cartStore: CartStore;
constructor() {
this.userStore = new UserStore(this);
this.cartStore = new CartStore(this);
makeAutoObservable(this);
}
}
export const rootStore = new RootStore();
// Using React Context
import { createContext, useContext } from 'react';
import { rootStore } from './stores/RootStore';
const StoreContext = createContext(rootStore);
export const useStore = () => useContext(StoreContext);
// In component
const { userStore, cartStore } = useStore();
Passing the root store into each child store’s constructor (new UserStore(this)) is the standard way for stores to reach each other — for example, CartStore can read this.root.userStore.currentUser to apply a member discount in a computed. That creates a circular reference between stores, which is fine for garbage collection but means construction order matters: a child store must not access a sibling in its constructor, because the sibling may not exist yet. The context default value makes useStore() work without a provider; for tests and SSR, wrap the tree in <StoreContext.Provider value={new RootStore()}> so each test or request gets fresh state. Destructuring userStore and cartStore is safe because they are stable references; destructuring observable values (const { count } = store) outside the render function breaks tracking.
Mutating observable arrays inside actions
// Good
addItem(item) {
this.items.push(item);
}
removeItem(id) {
this.items = this.items.filter(i => i.id !== id);
}
// Also good (with MobX) — but guard the index
removeItem(id) {
const index = this.items.findIndex(i => i.id === id);
if (index !== -1) this.items.splice(index, 1);
}
Unlike Redux or React state, MobX does not require immutable updates: push, splice, and direct assignment to fields are all observed. Replacing the array with filter and mutating it with splice both work; the difference is that replacement changes the array’s identity, which matters if some component holds a reference to the old array or passes it to a memoized child. Guard the findIndex result: splice(-1, 1) removes the last element, so an unknown id silently deletes the wrong item. One more interop trap: MobX arrays are Proxies, and APIs that need plain data — structuredClone, postMessage to a worker, some charting and grid libraries — can fail or behave oddly with them; pass toJS(store.items) or store.items.slice() to such code.
Keeping re-renders narrow with observer
Make list items their own observers
// Good: each item is its own observer — editing one todo's text
// re-renders only that TodoItem
const TodoItem = observer(({ todo }) => {
return <div>{todo.text}</div>;
});
// The list only reads the array itself, so it re-renders only when
// todos are added, removed, or reordered
const TodoList = observer(() => {
return store.todos.map(todo => <TodoItem key={todo.id} todo={todo} />);
});
The point of making small components observers is that each one tracks only what it reads. If TodoList rendered {todo.text} inline instead of delegating to an observer TodoItem, editing any todo’s text would re-render the whole list. observer also wraps the component in React.memo, so a child whose props did not change is skipped when its parent re-renders.
Dereference values late
// Good: the child dereferences user.name itself, so only UserName
// re-renders when the name changes
const UserName = observer(({ user }) => {
return <div>{user.name}</div>;
});
// Worse: the parent must read user.name to pass it down, so the
// parent re-renders on every name change as well
const UserName = observer(({ userName }) => {
return <div>{userName}</div>;
});
“Dereference late” means passing observable objects down and reading their fields as deep in the tree as possible, because whoever reads a field is the one that re-renders when it changes. The same rule explains a frequent bug with non-observer children: passing user to a third-party component that reads user.name internally does not subscribe anything, since that component is not an observer. In that case, dereference in your observer component and pass the primitive value.
Frequently Asked Questions (FAQ)
Q. Why does MobX warn about modifying state outside an action after an await?
A. An action only covers the synchronous part of a function. In an async method, everything after the first await runs in a later tick, outside the original action, so MobX’s default enforceActions setting warns when observed state is changed there. Wrap the post-await updates in runInAction(() => { ... }), as in the async example above, or write the method as a generator with flow, which MobX wraps in actions for you.