MobX
Observable state with automatic dependency tracking. Low interview frequency in 2026 — you meet it in existing codebases rather than new ones — so this is deliberately one focused note rather than a folder. For what to choose today, see ../choosing_a_state_library.md.
The model
Four concepts, and everything else follows:
| Concept | Role |
|---|---|
| Observable | state MobX tracks |
| Computed | derived value, cached, recomputed only when a dependency changes |
| Action | the only place state should be mutated; batches the resulting reactions |
| Reaction | a side effect that re-runs when what it read changes (autorun, reaction, observer) |
import { makeAutoObservable, runInAction } from 'mobx';
class TodoStore {
todos = [];
filter = 'all';
constructor() { makeAutoObservable(this); }
get visible() { // computed - cached
return this.filter === 'all'
? this.todos
: this.todos.filter((t) => t.done === (this.filter === 'done'));
}
add(text) { this.todos.push({ text, done: false }); } // action
async load() {
const res = await api.list();
runInAction(() => { this.todos = res; }); // see below
}
}
makeAutoObservable infers the roles: fields become observable, getters become computed, methods become actions. The older makeObservable with an explicit annotation map is what you find in pre-6 code.
The parts that trip people up
Mutation is how you update. this.todos.push(x) is correct. MobX wraps state in a Proxy and sees the mutation — the opposite of Redux, and the opposite of React state. This is the single biggest adjustment coming from either.
Everything after an await is outside the action. The function resumes in a new tick, so MobX no longer treats it as an action and mutating there triggers a warning under strict mode. Wrap the post-await mutation in runInAction, or use flow with a generator.
Computed values only cache while observed. A computed read outside any reaction or observer component recomputes every access. That surprises people benchmarking a getter in isolation and concluding the cache does not work.
observer placement decides your performance. Wrapping only the top component means every state change re-renders the whole tree; wrapping the leaf that actually reads the value gives fine-grained updates. Granular observer components is the entire performance story.
Destructuring loses reactivity. const { count } = store reads the value once. Pass the store and read store.count inside the observed render, or the component never updates. Same trap as Vue’s reactive — see ../../06_vue/03_ref_vs_reactive.md.
With React
import { observer } from 'mobx-react-lite';
const TodoList = observer(() => (
<ul>{store.visible.map((t) => <li key={t.text}>{t.text}</li>)}</ul>
));
mobx-react-lite is the function-component package and the one to use; mobx-react exists for class components. Stores are supplied by module import or React context — a root store holding domain stores is the conventional structure, with child stores taking a reference to the root so they can reach each other.
Under React 18+ concurrent rendering, observer uses useSyncExternalStore so external state cannot tear across a render.
MobX versus Redux
| MobX | Redux | |
|---|---|---|
| Update mechanism | mutate observables | dispatch action, pure reducer returns new state |
| Boilerplate | minimal | low with RTK, high without |
| Traceability | implicit — a reaction fires because something it read changed | explicit — every change is a logged action |
| Time-travel debugging | not really | first-class |
| Re-render granularity | automatic, per observed value | manual, via selectors and comparison |
| Scales by | conventions the team enforces | the architecture itself |
The trade is honest: MobX gets you further with less code, and gives up the auditability that makes a large Redux codebase debuggable. On a big team, “why did this change” is easier to answer in Redux.
SSR
Create a fresh store per request — a module-level singleton leaks one user’s state into another’s response, which is the security bug worth naming. Serialise the store into the payload and hydrate on the client. Reactions must not start on the server, since nothing tears them down.
Testing
Stores are plain objects, so test them directly: call an action, assert on state and computed values. reaction returns a disposer — call it, or the test suite accumulates live reactions across tests. For components, mobx-react-lite’s observer works normally under Testing Library.
Interview angle
- “How does MobX know what to re-render?” - it records which observables were read during a reaction or an
observercomponent’s render, and re-runs exactly those when one changes. No selectors, no dependency arrays, no comparison function. - “Why does my state change not update the component?” - almost always one of three things: the component is not wrapped in
observer, the value was destructured out of the store before render, or the object was created beforemakeAutoObservableand is not actually observable. - “Why do I get a warning about mutating outside an action?” - the code after an
awaitruns in a new tick and is no longer inside the action. UserunInActionor aflowgenerator. - “MobX or Redux?” - MobX for less ceremony and automatic fine-grained updates; Redux for explicit, auditable state transitions and time-travel debugging. On a large team the traceability usually wins, which is why MobX has receded.
- “Would you start a new project on MobX in 2026?” - probably not. Server state belongs in a query library, and for client state Zustand or Jotai give a smaller, more familiar API. MobX is a maintenance skill — know the model well enough to work in an existing codebase.