MobX is a state management library built on one promise: you never subscribe to anything by hand. You mark state as observable, mark the code that changes it as actions, and wrap the widgets that read it in observers. The library tracks, at runtime, which piece of state each observer actually read, and rebuilds exactly those observers when exactly that state changes. No selectors, no listeners to wire up, no lists of dependencies to keep current.
MobX was born in the JavaScript world and came to Dart as mobx.dart, with a Flutter binding in flutter_mobx. This post walks through the mental model, a store that does real work, async state, the rebuild granularity that makes MobX fast by default, reactions for side effects, and an honest take on where MobX fits among Flutter's state management options.
The mental model
MobX has three core concepts, and every app you build with it is some arrangement of the three:
- Observables hold your state. A field, a list, a future: anything a widget or another value might care about.
- Actions are the methods that change observables. Changes inside an action are batched, so the UI never sees a half-applied update.
- Reactions are the consumers. The
Observerwidget is a reaction that rebuilds UI;reaction,autorunandwhenare reactions that run side effects.
Between state and consumers sits a fourth piece that does most of the quiet work: computed values, which derive new values from observables and cache the result until one of their inputs changes.

The property that separates MobX from most alternatives is that the dependency graph is discovered, not declared. Whatever an Observer dereferences during its build becomes its dependency set for the next change. Read less, rebuild less. You do not tell MobX what to watch; the read is the subscription.
Setup and code generation
MobX in Dart leans on code generation for the annotation syntax:
dependencies:
mobx: ^2.0.0
flutter_mobx: ^2.0.0
dev_dependencies:
build_runner: ^2.0.0
mobx_codegen: ^2.0.0
A store is an abstract class with a Store mixin, annotations on its members, and a generated part file that wires the reactivity in. During development, keep the generator running with dart run build_runner watch --delete-conflicting-outputs, and the .g.dart files stay current as you type. It is a real cost in the inner loop, and worth knowing about upfront; the closing section comes back to it.
A store that earns its keep
Counters do not show you why MobX is pleasant, because a counter has no derived state. A cart does:
import 'package:mobx/mobx.dart';
part 'cart_store.g.dart';
class CartStore = _CartStore with _$CartStore;
abstract class _CartStore with Store {
@observable
ObservableList<CartItem> items = ObservableList.of([]);
@computed
double get total =>
items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
@computed
bool get canCheckout => items.isNotEmpty;
@action
void add(CartItem item) => items.add(item);
@action
void changeQuantity(CartItem item, int quantity) {
item.quantity = quantity; // quantity is @observable on CartItem
}
}
The point of this shape is what is missing. There is no _total field being carefully kept in sync with the list, no notifyListeners() sprinkled through the mutations, and no chance for the total and the items to disagree. total is a @computed: it is derived, cached, and recomputed only when items or a quantity inside it changes. The rule of thumb that keeps MobX stores clean: store the minimum, derive the rest.
Async state without flags
The classic async mess is three fields (isLoading, error, data) that can drift into impossible combinations. ObservableFuture folds them into one value with a status:
abstract class _SearchStore with Store {
_SearchStore(this._api);
final CatalogApi _api;
@observable
ObservableFuture<List<Product>>? results;
@action
void search(String query) {
results = ObservableFuture(_api.search(query));
}
}
The widget side reads the status and cannot forget a case, because the compiler checks the switch:
Observer(
builder: (_) => switch (store.results?.status) {
null => const SearchPrompt(),
FutureStatus.pending => const Center(child: CircularProgressIndicator()),
FutureStatus.rejected => const SearchErrorView(),
FutureStatus.fulfilled => ProductGrid(items: store.results!.value ?? []),
},
)
Observer and granular rebuilds
Observer is where MobX earns its performance without you doing anything. Each Observer rebuilds only when something it read during its last build changes. Wrap small widgets, get small rebuilds:

When a quantity changes in the cart above, two things rebuild: the row whose Observer read that item, and the total label whose Observer read the total computed. The header, the list container and the checkout button are not rebuilt, not diffed, not touched. You did not memoize anything to get that.
The one rule that trips people up: the read must happen inside the builder. Hoist the value out and the tracking is gone:
// Wrong: the read happens outside the builder, so nothing is tracked
// and this Text never updates.
final total = store.total;
return Observer(builder: (_) => Text('$total'));
// Right: the builder itself reads the observable.
return Observer(builder: (_) => Text('${store.total}'));
Reactions: side effects with a lifecycle
Not every consumer of state is a widget. Navigation after a login, a snackbar on an error, persisting a draft: these are side effects, and MobX gives them their own primitives. reaction runs an effect when a tracked value changes, autorun runs immediately and on every change, when fires once and disposes itself.
Every reaction returns a disposer, and the discipline that keeps apps leak-free is pairing it with the widget lifecycle:
class _CartScreenState extends State<CartScreen> {
late final ReactionDisposer _checkoutWatch;
@override
void initState() {
super.initState();
// Fires when canCheckout flips, not on every cart mutation.
_checkoutWatch = reaction(
(_) => widget.store.canCheckout,
(bool ready) {
if (ready) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ready to check out')),
);
}
},
);
}
@override
void dispose() {
_checkoutWatch();
super.dispose();
}
}
Note what reaction tracks: the predicate's value, not the underlying mutations. Ten quantity changes that leave canCheckout true fire the effect zero times.
Testing stores as plain Dart
A MobX store is an ordinary object. No widget harness, no pumping frames, no fakes for the framework. Actions are method calls and computed values are synchronous reads, so tests read like a spec of the behaviour:
test('total follows quantity changes', () {
final store = CartStore();
store.add(CartItem(price: 12.0, quantity: 1));
expect(store.total, 12.0);
store.changeQuantity(store.items.first, 3);
expect(store.total, 36.0);
});
This is a quiet argument for keeping logic in stores and keeping widgets thin: the valuable behaviour of the app becomes testable at Dart speed.
Where MobX fits
MobX is at its best where derived state dominates: form-heavy screens, dashboards, carts, filters, anything where many small values feed many small labels. The combination of computed values and granular observers means the "recalculate and update everything that depends on this" problem, which is most of UI state management, simply stops existing as a category of work. Teams that want minimal ceremony get productive with it fast.
The costs are just as concrete. Code generation puts build_runner in your inner loop and generated files in your diffs. And the same implicit tracking that makes small screens effortless makes large codebases harder to audit: the dependency graph exists at runtime, discovered from reads, and no file in the repo shows it to you. On a single app with a small team, that trade is often worth it. In a large modular codebase where the graph itself needs to be explicit, reviewable and enforced, an explicit tool fits better; that reasoning, applied to a three-app workspace built on hand-written Riverpod, is covered in the monorepo article.
Pick MobX when you want reactivity you can mostly stop thinking about. Just keep the two disciplines that keep it healthy at any size: store the minimum and derive the rest, and give every reaction a disposer.