MobX #
MobX is one of the state management libraries in the Flutter ecosystem that applies the Transparent Functional Reactive Programming (TFRP) philosophy. This approach lets app data, the user interface (UI), and business logic connect automatically and dynamically through an invisible observation mechanism. Originally developed in the JavaScript ecosystem and very popular in the React community, MobX was ported to Dart while retaining its main principle: anything that can be derived automatically from application state should be derived automatically.
For those of you who want to build apps with high reactivity without writing lots of boilerplate code or manually managing UI updates, MobX offers a very elegant solution. In this document, we’ll break down MobX’s core concepts, understand how its pillars work, learn best practices, and implement it in real-world scenarios.
The Basics of Transparent Functional Reactive Programming (TFRP) #
Before diving into the technical implementation, it’s important to understand the paradigm behind MobX. Transparent Functional Reactive Programming (TFRP) is a form of reactive programming where you don’t need to explicitly subscribe to a data flow (stream or observable). MobX transparently detects which variables are read during a function’s execution and automatically establishes dependency relationships.
In other state management libraries like Bloc or even Provider, you often have to manually define which part triggers changes and which widgets should listen to those changes. With TFRP, MobX performs this dependency tracking in the background. When an observable variable changes, all computed calculations or widgets using that variable are immediately updated. This eliminates human errors like forgetting to update the UI or over-rendering.
MobX’s Three Main Pillars #
MobX’s architecture rests on three main concepts that interconnect to form a one-way cycle (unidirectional data flow). These three concepts are Observables, Actions, and Reactions. Additionally, there are Computed Properties acting as a smart bridge between Observables and Reactions.
Here’s a MobX workflow diagram illustrating how actions trigger state changes, which then update the UI and run side effects automatically:
graph TD
classDef default stroke:#333,stroke-width:2px;
A["Actions (Store Methods)"] -->|Change| B["Observables (Reactive State)"]
B -->|Trigger Re-computation| C["Computed Properties (Derived State)"]
B -->|Trigger Side Effects| D["Reactions (Side Effects & UI Rebuild)"]
C -->|Used by| D
D -->|Send Events / Interaction| ALet’s break down each pillar component in depth:
- Observables (State): Observables represent the state or raw data of your app that can change over time. Every change to an observable’s value is recorded by MobX and then notified to all consumers that need it.
- Actions: Actions are methods or functions responsible for changing the values of observable variables. In MobX, all state mutations must happen inside an Action to ensure orderly change tracking and efficient UI update batching.
- Computed Properties (Derived State): Computed properties are values derived from other observables. These values are automatically cached (memoized) and only recalculated when the underlying observable values change.
- Reactions (Side Effects): Reactions are similar to computed properties, but instead of producing new values, reactions produce side effects like printing logs, sending data to an API, saving data locally, or rebuilding widgets on screen (via
Observer).
Installation and Project Configuration #
To use MobX in Flutter, you need several libraries in your pubspec.yaml file. Because MobX uses code generation to reduce repetitive boilerplate writing, you need several libraries in both the dependencies and dev_dependencies sections.
Add the following lines to your project’s pubspec.yaml configuration file:
dependencies:
flutter:
sdk: flutter
# MobX main library for Dart
mobx: ^2.5.0
# MobX integration with the Flutter widget ecosystem
flutter_mobx: ^2.3.0
dev_dependencies:
flutter_test:
sdk: flutter
# Tool for running the code generator in Dart
build_runner: ^2.9.0
# Code generator specific to MobX Store classes
mobx_codegen: ^2.7.4
After adding those dependencies, run the following command in your project terminal to download the libraries:
flutter pub get
Creating Your First Store #
A Store in MobX is a container class where you put all related observable variables, computed properties, and actions. To minimize boilerplate, you use generator annotations from mobx_codegen.
Let’s create a simple counter store to understand MobX’s basic file structure:
// counter_store.dart
import 'package:mobx/mobx.dart';
// Required: Connects this file with the generated file
part 'counter_store.g.dart';
// The main class accessed by the UI.
// A combination of the abstract class and the generated mixin.
class CounterStore = _CounterStore with _$CounterStore;
// The abstract class where you write the main business logic.
abstract class _CounterStore with Store {
@observable
int value = 0;
@action
void increment() {
value++;
}
@action
void decrement() {
value--;
}
@action
void reset() {
value = 0;
}
}
After writing the code above, the Dart analyzer will show error messages because the counter_store.g.dart file doesn’t exist yet. You have to run the code generator to create it.
Running build_runner #
Open a terminal in your project directory and run the following command:
# Running the build once
flutter pub run build_runner build --delete-conflicting-outputs
If you’re in an active development phase, running the command above continuously can be very tiring. You can use the watch mode so the generator automatically runs every time there’s a change to store files:
# Running the build in real-time every time a file is saved
flutter pub run build_runner watch --delete-conflicting-outputs
The command above will generate a counter_store.g.dart file containing all the reactivity implementations behind the scenes, so your main code stays clean and readable.
Observables: Reactive State #
The @observable annotation tells the MobX generator to make that variable reactive. MobX will watch whenever this variable is read and whenever its value changes.
Primitive Data Types vs Collections #
For primitive data types like int, double, String, and bool, you can use them directly as usual. However, for collection data types like List, Map, and Set, you must not use Dart’s built-in data types directly if you want to track changes inside those collections.
Consider this common mistake example:
// WRONG: Changes to elements inside the list won't be detected
@observable
List<String> taskList = [];
// In the action section:
void addTask(String task) {
// The list's memory address doesn't change, MobX doesn't detect element mutations!
taskList.add(task);
}
To solve reactivity problems on collections, MobX provides special reactive collection types: ObservableList, ObservableMap, and ObservableSet:
// CORRECT: Use MobX collection data types
@observable
ObservableList<String> taskList = ObservableList<String>();
// In the action section:
@action
void addTask(String task) {
// Every element addition or removal automatically triggers a UI rebuild
taskList.add(task);
}
The Readonly Annotation #
Sometimes you want an observable variable to be readable by the UI but only changeable from inside the store itself. MobX provides the @readonly annotation for this scenario:
abstract class _AuthStore with Store {
// Generates a public getter but keeps the setter private to the store
@readonly
String? _token;
@action
void setToken(String newToken) {
_token = newToken;
}
}
Using @readonly, you prevent outside widgets from accidentally modifying state outside the defined actions.
Actions: Changing State in a Controlled Way #
Actions are methods responsible for modifying state. Why should you use actions? Why not just change observable variable values directly from the UI? There are several important reasons:
- Update Batching: If an action changes five different observable variables, MobX holds change notifications to the UI until the entire action body finishes executing. The UI only rebuilds once, not five times. This significantly improves app rendering performance.
- Readability & Debugging: By tracking mutations only inside actions, you can easily trace where state changes come from.
- Enforcing Centralized Mutation: You can configure MobX to forbid observable mutations outside actions (the
enforceActionsoption).
Async Actions #
Real apps are full of async operations like network API calls or local database reads. Writing async actions in MobX requires special attention because after the await keyword, the code execution path is outside the original action context.
Here’s the safe and recommended way to write async actions:
abstract class _UserStore with Store {
@observable
bool isLoading = false;
@observable
String? username;
@observable
String? errorMessage;
@action
Future<void> fetchUser(int id) async {
isLoading = true;
errorMessage = null;
try {
// Async I/O operations run without blocking
final result = await apiService.getUserName(id);
// Changing state after await must be done safely.
// You can assign it directly if using the latest MobX version,
// or wrap it in 'runInAction' if enforceActions mode is strictly enabled.
runInAction(() {
username = result;
isLoading = false;
});
} catch (e) {
runInAction(() {
errorMessage = e.toString();
isLoading = false;
});
}
}
}
Using runInAction ensures that state mutations happening after the async operation completes are still considered to be in an action context, so batching and tracking rules keep working properly.
Computed Properties: Derived State #
Computed properties are marked with the @computed annotation on getter methods. The computed concept is very important for keeping your store free of data redundancy. Computed values are derived from other observables and the results are stored in memory (cached).
The Advantage of Memoization #
If you call a computed property repeatedly in different widgets, MobX won’t recalculate its formula. MobX just returns the cached value. Recalculation only runs when one of the observables used in its formula changes.
abstract class _CartStore with Store {
@observable
ObservableList<CartItem> items = ObservableList();
// First computed property
@computed
double get subtotal => items.fold(0, (sum, item) => sum + item.totalPrice);
// Second computed property depending on the first computed property
@computed
double get tax => subtotal * 0.11;
// Third computed property combining multiple values
@computed
double get totalDue => subtotal + tax;
@computed
bool get isEmpty => items.isEmpty;
}
With a structure like this, you don’t need to manually maintain subtotal, tax, and totalDue variables in every add or remove item action. Just manage the items list inside actions, and the rest is automatically resolved by computed properties.
Reactions: Managing Side Effects #
Reactions are used when you want to run non-UI logic (side effects) in response to observable changes. MobX provides three reaction types: autorun, reaction, and when.
1. autorun #
autorun runs immediately once when first defined, then runs again every time any observable variable inside it changes value.
final disposer = autorun((_) {
print('The current value is ${store.value}');
});
2. reaction #
Unlike autorun, reaction doesn’t run immediately when defined. It accepts two function parameters: the first function detects the data being monitored (tracker), and the second function is the side effect executed when the monitored data changes.
final disposer = reaction(
(_) => store.isLoggedIn, // Monitoring login status changes
(bool loggedIn) {
if (loggedIn) {
navigationService.goToHome();
} else {
navigationService.goToLogin();
}
},
);
3. when #
when only runs once. It monitors a certain condition (the first function must return a boolean value), and when that condition becomes true, it runs the side effect function (the second function) and then immediately disposes itself (auto-dispose).
final disposer = when(
(_) => store.downloadProgress == 100, // Waiting for the condition to be met
() => notificationService.showComplete(), // Run once
);
Important: Cleaning Up Reactions #
Every time you create an autorun, reaction, or when, the function returns a ReactionDisposer object. You must store this disposer and call it when the store or widget is destroyed to prevent memory leaks.
class DetailPage extends StatefulWidget {
const DetailPage({super.key});
@override
State<DetailPage> createState() => _DetailPageState();
}
class _DetailPageState extends State<DetailPage> {
late ReactionDisposer _disposer;
final _store = DetailStore();
@override
void initState() {
super.initState();
// Setup a reaction to monitor error messages
_disposer = reaction(
(_) => _store.errorMessage,
(String? msg) {
if (msg != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
},
);
}
@override
void dispose() {
_disposer(); // Disposing the reaction to prevent memory leaks
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(),
);
}
}
Observer Widget: Automatic UI Synchronization #
To connect a MobX store with the Flutter user interface, you use the Observer widget provided by the flutter_mobx library.
The Observer widget automatically tracks which observables are read inside its builder parameter. When any of those observables change, Observer triggers an instant rebuild on that widget only.
Granular Rebuild Tips #
The key to high performance when using MobX is keeping the Observer widget scope as small as possible. Avoid wrapping an entire page in one big Observer widget if only a small part of the elements needs reactivity.
Let’s compare the two patterns below:
// WRONG PATTERN: Inefficient macro rebuild
Observer(
builder: (context) {
return Scaffold(
appBar: AppBar(title: Text('Profile ${store.username}')),
body: Column(
children: [
const BigWorldMapWidget(), // Heavy static widget gets rebuilt too!
Text('Game Score: ${store.score}'),
],
),
);
},
)
// CORRECT PATTERN: Efficient granular rebuild
Scaffold(
appBar: AppBar(
title: Observer(builder: (_) => Text('Profile ${store.username}')),
),
body: Column(
children: [
const BigWorldMapWidget(), // Safe, won't rebuild because it's outside the Observer
Observer(builder: (_) => Text('Game Score: ${store.score}')),
],
),
)
By isolating Observer to just the score text component, you save device computing power because heavy static widgets like the world map don’t need to be rebuilt every time the player’s score increases.
Case Study: Implementing a Modern Online Store #
Let’s combine all the understanding above into a real online store app scenario. We’ll create a shopping item model, a store to manage the shopping list, and a reactive user interface.
1. Data Model #
// cart_item.dart
class CartItem {
final String id;
final String name;
final double price;
CartItem({
required this.id,
required this.name,
required this.price,
});
}
2. Implementing the Shopping Cart Store #
// cart_store.dart
import 'package:mobx/mobx.dart';
import 'cart_item.dart';
part 'cart_store.g.dart';
class CartStore = _CartStore with _$CartStore;
abstract class _CartStore with Store {
// Using ObservableList so element additions/removals are detected
@observable
ObservableList<CartItem> shoppingList = ObservableList<CartItem>();
@observable
bool isProcessing = false;
@observable
String? extraNote;
// Computed state for the total price
@computed
double get totalPrice => shoppingList.fold(0.0, (sum, item) => sum + item.price);
// Computed state for the number of unique items
@computed
int get itemCount => shoppingList.length;
// Computed state to check whether the cart is empty
@computed
bool get isEmpty => shoppingList.isEmpty;
@action
void addItem(CartItem item) {
shoppingList.add(item);
}
@action
void removeItem(CartItem item) {
shoppingList.remove(item);
}
@action
void setNote(String text) {
extraNote = text;
}
@action
Future<void> processCheckout() async {
if (isEmpty) return;
isProcessing = true;
try {
// Simulating sending data to the server API
await Future.delayed(const Duration(seconds: 2));
shoppingList.clear();
extraNote = null;
} finally {
isProcessing = false;
}
}
}
3. Building the Flutter UI #
// cart_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'cart_store.dart';
import 'cart_item.dart';
class CartPage extends StatelessWidget {
final CartStore store;
const CartPage({super.key, required this.store});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Our Shopping Cart'),
),
body: Column(
children: [
Expanded(
child: Observer(
builder: (_) {
if (store.isEmpty) {
return const Center(child: Text('Your cart is empty.'));
}
return ListView.builder(
itemCount: store.itemCount,
itemBuilder: (context, index) {
final item = store.shoppingList[index];
return ListTile(
title: Text(item.name),
subtitle: Text('Rp ${item.price.toStringAsFixed(0)}'),
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => store.removeItem(item),
),
);
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: const InputDecoration(
labelText: 'Shopping Note',
border: OutlineInputBorder(),
),
onChanged: store.setNote,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total Payment:',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Observer(
builder: (_) => Text(
'Rp ${store.totalPrice.toStringAsFixed(0)}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
],
),
const SizedBox(height: 16),
Observer(
builder: (_) {
return ElevatedButton(
onPressed: (store.isEmpty || store.isProcessing)
? null
: () => store.processCheckout(),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: store.isProcessing
? const CircularProgressIndicator()
: const Text('Process Checkout'),
);
},
),
],
),
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
final uniqueId = DateTime.now().millisecondsSinceEpoch.toString();
store.addItem(
CartItem(
id: uniqueId,
name: 'Item #$uniqueId',
price: 25000.0,
),
);
},
child: const Icon(Icons.add_shopping_cart),
),
);
}
}
Integrating MobX with Provider for Dependency Injection #
Although MobX manages reactive state very well, MobX doesn’t provide a mechanism for sharing store instances across the entire widget tree. To solve this, you combine MobX with the provider library as a Dependency Injection (DI) service provider.
Here’s how to integrate MobX and Provider in the main entry file (main.dart):
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'cart_store.dart';
import 'cart_page.dart';
void main() {
runApp(
MultiProvider(
providers: [
// Registering the MobX store so it can be accessed by widgets below
Provider<CartStore>(
create: (_) => CartStore(),
),
],
child: const OurApp(),
),
);
}
class OurApp extends StatelessWidget {
const OurApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Our Reactive Store',
theme: ThemeData(primarySwatch: Colors.blue),
home: Consumer<CartStore>(
builder: (context, store, _) {
return CartPage(store: store);
},
),
);
}
}
If you want to access the store from the deepest widget without using Consumer, you can read it directly using context.read():
class QuickAddButton extends StatelessWidget {
const QuickAddButton({super.key});
@override
Widget build(BuildContext context) {
// Reading the store instance without subscribing to updates (because the action doesn't rebuild this widget)
final store = context.read<CartStore>();
return ElevatedButton(
onPressed: () {
store.addItem(
CartItem(
id: 'quick',
name: 'Quick Product',
price: 15000.0,
),
);
},
child: const Text('Quick Add'),
);
}
}
Testing MobX Stores #
One of the biggest advantages of separating logic from the UI using MobX stores is how easy it is to write unit tests. Because the store doesn’t depend on Flutter context or the widget tree, you can quickly test its behavior using regular Dart unit tests.
Here’s a test file for our CartStore:
// test/cart_store_test.dart
import 'package:flutter_test/flutter_test.dart';
import '../lib/cart_store.dart';
import '../lib/cart_item.dart';
void main() {
group('CartStore Business Logic Testing', () {
late CartStore store;
setUp(() {
store = CartStore();
});
test('The cart should start in an empty state', () {
expect(store.isEmpty, isTrue);
expect(store.itemCount, equals(0));
expect(store.totalPrice, equals(0.0));
});
test('Adding an item should update the list and total price', () {
final item = CartItem(id: '1', name: 'Dart Book', price: 50000.0);
store.addItem(item);
expect(store.isEmpty, isFalse);
expect(store.itemCount, equals(1));
expect(store.totalPrice, equals(50000.0));
expect(store.shoppingList.first.name, equals('Dart Book'));
});
test('Removing an item should update the list and total price', () {
final item1 = CartItem(id: '1', name: 'Dart Book', price: 50000.0);
final item2 = CartItem(id: '2', name: 'Milk Coffee', price: 20000.0);
store.addItem(item1);
store.addItem(item2);
store.removeItem(item1);
expect(store.itemCount, equals(1));
expect(store.totalPrice, equals(20000.0));
expect(store.shoppingList.first.id, equals('2'));
});
test('The checkout process should empty the shopping cart', () async {
final item = CartItem(id: '1', name: 'Dart Book', price: 50000.0);
store.addItem(item);
// Run the async action
final future = store.processCheckout();
// While the process runs, the isProcessing state must be true
expect(store.isProcessing, isTrue);
await future;
// After completion, the state returns to normal and the cart is empty
expect(store.isProcessing, isFalse);
expect(store.isEmpty, isTrue);
});
});
}
With test coverage like this, you can verify your app’s business logic correctness very quickly without rendering any UI in a simulator.
MobX Anti-Patterns to Avoid #
To keep your app performing optimally and free from hard-to-trace bugs, make sure to avoid the following bad habits:
- Changing Observable Variables Directly Without Actions: Although MobX Dart still allows it by default when not strictly configured, modifying observables outside actions breaks the one-way architecture concept and makes mutation tracking difficult. Always wrap every data change inside
@actionorrunInAction. - Forgetting to Dispose Reactions: Every time you call
autorun,reaction, orwheninside a stateful widget, store the result and call the disposer in thedispose()method. Ignoring this causes memory leaks because the reaction keeps running in the background listening to state changes. - Using Dart’s Built-in List Directly: Always use
ObservableListor convert withObservableList.of()if you need a reactive array. Regular lists don’t trigger changes when elements are added or removed. - Too Much Logic in Computed Properties: Remember that computed properties are meant for lightweight derived data. Don’t make network API calls, database queries, or other heavy I/O operations inside
@computedgetter methods.
Summary #
- TFRP (Transparent Functional Reactive Programming) automatically connects data changes with the UI interface without needing explicit subscription management.
- MobX’s Three Pillars consist of Observables (reactive data), Actions (data modifiers), and Reactions (side effects of data changes).
- Computed Properties act as efficiently cached (memoized) derived state to avoid unnecessary repeated calculations.
- Reactive Collections must use special classes like
ObservableListandObservableMapso data changes inside them are perfectly detected by the UI.- Observer Widgets should be as specific (granular) as possible to avoid unnecessary static widget rebuilds for the best rendering performance.
- Reaction Disposers must be called when the widget is destroyed (
dispose()) to prevent memory resource buildup in the background.- Provider can be integrated as clean Dependency Injection (DI) to distribute MobX store instances to sub-widget trees.
- Unit Testing MobX stores is very easy to do independently because business logic is completely separated from the Flutter UI framework.
← Previous: Bloc & Cubit Next: Comparison & When to Choose →