MobX

4 min read index source

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 observer component’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 before makeAutoObservable and is not actually observable.
  • “Why do I get a warning about mutating outside an action?” - the code after an await runs in a new tick and is no longer inside the action. Use runInAction or a flow generator.
  • “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.