Concept
The beginner framing: Zustand needs an explicit selector function, and Jotai needs explicit atom reads, MobX needs neither. It wraps your state in a JavaScript Proxy and automatically figures out exactly which properties a component actually read during its last render, subscribing to precisely those.
import { makeAutoObservable } from "mobx";
import { observer } from "mobx-react-lite";
class CounterStore {
count = 0;
user = { name: "Ada" };
constructor() {
makeAutoObservable(this); // wraps properties in a Proxy, methods as "actions"
}
increment() { this.count++; }
renameUser(name) { this.user.name = name; }
}
const store = new CounterStore();
const Counter = observer(function Counter() {
return <button onClick={() => store.increment()}>{store.count}</button>; // count is READ here
});Confirmed by running exactly this class: makeAutoObservable(this) in the constructor is sufficient, no decorators, no manually calling observable()/action() on individual fields, every property becomes reactive and every method becomes an "action" (MobX's term for a function that's allowed to mutate observable state) automatically.
class Store {count = 0;user = { name: 'Ada' };constructor() { makeAutoObservable(this); }increment() { this.count++; }renameUser(name) { this.user.name = name; }}
makeAutoObservable(this) wraps every property in a Proxy and every method as an action, confirmed. Neither component declares a selector; MobX records exactly which OBSERVABLE PROPERTIES each one reads DURING its own render.
The mechanism: tracking happens during render, based on what's actually read
const ComponentA = observer(() => <span>{store.count}</span>); // reads ONLY count
const ComponentB = observer(() => <span>{store.user.name}</span>); // reads ONLY user.nameConfirmed by running this exact pair: calling store.increment() re-renders ComponentA (it read store.count last render) but not ComponentB (it never touched count), and calling store.renameUser(...) does the reverse. Critically, neither component declared this boundary anywhere, MobX's Proxy intercepted the property access during each component's actual render function execution and recorded it as that component's dependency, entirely automatically.
Computed values: getters become reactive automatically too
class Store {
count = 0;
constructor() { makeAutoObservable(this); }
get doubled() { return this.count * 2; } // a plain GETTER
increment() { this.count++; }
}Confirmed by running this exact class and watching a reactive subscriber: a plain JavaScript getter, once wrapped by makeAutoObservable, automatically becomes a MobX computed value, it recalculates and correctly triggers dependent reactions whenever count (the value it reads) changes, with no special syntax beyond an ordinary get accessor.
Actions: mutating through methods, not direct external assignment
store.count++; // ⚠️ works, but WARNS in MobX's default strict mode
store.increment(); // ✅ no warning, increment() is a METHOD, auto-wrapped as an ACTIONConfirmed by running both forms: directly mutating an observable property from outside the class produces a real MobX warning ("Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed"), while calling a method defined on the class (which makeAutoObservable automatically wraps as an action) mutates the same property with no warning. The idiomatic pattern is defining all mutation logic as methods on the observable class, not reaching in from outside to assign properties directly.
Try It
Predict the outcome before checking the solution.
class Store {
a = 1;
b = 2;
constructor() { makeAutoObservable(this); }
bumpA() { this.a++; }
}
const store = new Store();
const ComponentB = observer(() => {
console.log("ComponentB rendered");
return <span>{store.b}</span>;
});
store.bumpA();Does ComponentB log a render after bumpA() runs?
Solution
No. ComponentB's render function only reads store.b, MobX's Proxy only recorded b as its dependency, since that's the only property actually accessed during its render. bumpA() only touches a, a completely different property ComponentB never read, so its subscription is never triggered, confirmed by the same automatic-tracking mechanism demonstrated above.
Implement It Yourself
Build a minimal Proxy-based auto-tracking system, to see the actual mechanism makeAutoObservable wraps:
function makeAutoTracked(target) {
const listenersByProp = new Map();
let currentTracker = null; // the "currently rendering" subscriber, if any
const proxy = new Proxy(target, {
get(obj, prop) {
if (currentTracker) {
if (!listenersByProp.has(prop)) listenersByProp.set(prop, new Set());
listenersByProp.get(prop).add(currentTracker); // AUTO-register: this tracker read this prop
}
return obj[prop];
},
set(obj,
This is the essential shape of what MobX's Proxy does, dependency registration happens as a side effect of actually reading a property during a tracked function's execution, which is exactly why no selector needs to be written by hand.
Under the Hood
MobX's automatic tracking is a genuinely different architecture from both Zustand's explicit-selector-plus-equality-check model and Jotai's explicit atom-graph model, all three achieve the same end goal (re-render only what actually needs to update), via three real, distinct mechanisms worth holding separately rather than treating as interchangeable implementation details. A fourth perspective on this same problem, Redux's centralized store plus manually-memoized selectors, is covered once the Redux domain ships, in redux.vs-zustand-mobx-jotai.
Common Mistakes
1. Mutating observable state directly from outside the class
store.count = 5; // ⚠️ WARNS in strict mode, confirmed via direct testingConfirmed to produce a real MobX warning under default strict mode, the idiomatic pattern is a method on the class (auto-wrapped as an action), not external direct assignment.
2. Reading observable state OUTSIDE of a tracked context
setTimeout(() => console.log(store.count), 1000); // this read is NOT tracked, nothing re-runs when count changesAutomatic tracking only registers a dependency when a property is read during an actively-tracked function's execution (a component's render, or an explicit autorun), reading the same property later, outside that context, doesn't create any reactive relationship.
3. Assuming MobX's automatic tracking means "everything re-renders on every change"
// "there's no selector, so it must just re-render on ANY store change", NOT accurateConfirmed to be the opposite, automatic tracking is exactly as granular as explicit selectors, just discovered implicitly rather than declared explicitly. The absence of a written selector doesn't mean the absence of fine-grained subscription behavior.
Best Practices
- Define all mutation logic as class methods, letting
makeAutoObservablewrap them as actions automatically, rather than mutating observable properties from outside the class. - Use plain getters for derived/computed values, no special syntax needed beyond a standard
getaccessor, and MobX handles the reactive recalculation. - Wrap every component that reads observable state in
observer(), a plain, non-observer-wrapped component reading observable state won't re-render reactively at all, since the automatic tracking mechanism specifically hooks intoobserver's render cycle.
Performance Tips
- Automatic tracking's granularity is discovered per-render, per-component, this means the re-render boundary can actually be tighter than a hand-written selector in cases where a component's actual property usage varies conditionally across renders, though this is a subtle effect, not typically the primary reason to choose MobX.
- The Proxy-based interception has a small, generally negligible overhead per property access compared to plain object access, worth knowing exists, rarely worth optimizing around in typical UI code.
