
Introduction to MobX
MobX is a battle-tested library that makes state management simple and scalable through reactive programming. Unlike Redux’s explicit and functional approach, MobX uses object-oriented programming and reactive principles to automatically update your UI when state changes. In this article, we’ll discuss state management with MobX.
Key Principles of MobX
- Observables: State that can be observed for changes
- Actions: Functions that modify the state
- Computed Values: Derived values that update automatically
- Reactions: Side effects that run when observables change
Setting Up MobX
First, install MobX and the React bindings:
npm install mobx mobx-react-lite
# or
yarn add mobx mobx-react-lite
For class components, you might also need:
npm install mobx-react
Basic MobX Concepts
1. Creating Observable State
import { makeObservable, observable, action, computed } from 'mobx';
class TodoStore {
todos = [];
filter = 'all';
constructor() {
makeObservable(this, {
todos: observable,
filter: observable,
addTodo: action,
toggleTodo: action,
setFilter: action,
filteredTodos: computed,
completedTodosCount: computed
});
}
addTodo(text) {
this.todos.push({
id: Date.now(),
text,
completed: false
});
}
toggleTodo(id) {
const todo = this.todos.find(todo => todo.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}
setFilter(filter) {
this.filter = filter;
}
get filteredTodos() {
switch (this.filter) {
case 'completed':
return this.todos.filter(todo => todo.completed);
case 'active':
return this.todos.filter(todo => !todo.completed);
default:
return this.todos;
}
}
get completedTodosCount() {
return this.todos.filter(todo => todo.completed).length;
}
}
// Create a store instance
const todoStore = new TodoStore();
export default todoStore;
2. Using MobX with React Components
import React from 'react';
import { observer } from 'mobx-react-lite';
import todoStore from './TodoStore';
const TodoApp = observer(() => {
const [inputValue, setInputValue] = React.useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
todoStore.addTodo(inputValue.trim());
setInputValue('');
}
};
return (
<div className="todo-app">
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<div className="filters">
<button
onClick={() => todoStore.setFilter('all')}
className={todoStore.filter === 'all' ? 'active' : ''}
>
All ({todoStore.todos.length})
</button>
<button
onClick={() => todoStore.setFilter('active')}
className={todoStore.filter === 'active' ? 'active' : ''}
>
Active ({todoStore.todos.length - todoStore.completedTodosCount})
</button>
<button
onClick={() => todoStore.setFilter('completed')}
className={todoStore.filter === 'completed' ? 'active' : ''}
>
Completed ({todoStore.completedTodosCount})
</button>
</div>
<ul className="todo-list">
{todoStore.filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
);
});
const TodoItem = observer(({ todo }) => {
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => todoStore.toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
</li>
);
});
export default TodoApp;
Advanced MobX Patterns
1. Multiple Stores
// UserStore.js
import { makeObservable, observable, action, computed } from 'mobx';
class UserStore {
currentUser = null;
users = [];
isLoading = false;
constructor() {
makeObservable(this, {
currentUser: observable,
users: observable,
isLoading: observable,
login: action,
logout: action,
fetchUsers: action,
setLoading: action,
isLoggedIn: computed
});
}
login(user) {
this.currentUser = user;
}
logout() {
this.currentUser = null;
}
setLoading(loading) {
this.isLoading = loading;
}
async fetchUsers() {
this.setLoading(true);
try {
const response = await fetch('/api/users');
const users = await response.json();
this.users = users;
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
this.setLoading(false);
}
}
get isLoggedIn() {
return !!this.currentUser;
}
}
export default new UserStore();
2. Root Store Pattern
// RootStore.js
import TodoStore from './TodoStore';
import UserStore from './UserStore';
class RootStore {
 constructor() {
  this.todoStore = new TodoStore(this);
  this.userStore = new UserStore(this);
 }
}
export default new RootStore();
3. Context Provider for Multiple Stores
// StoreContext.js
import React, { createContext, useContext } from 'react';
import RootStore from './RootStore';
const StoreContext = createContext(RootStore);
export const StoreProvider = ({ children }) => {
return (
<StoreContext.Provider value={RootStore}>
{children}
</StoreContext.Provider>
);
};
export const useStore = () => {
const context = useContext(StoreContext);
if (!context) {
throw new Error('useStore must be used within a StoreProvider');
}
return context;
};
// Using in a component
const SomeComponent = observer(() => {
const { todoStore, userStore } = useStore();
return (
<div>
<h2>Hello, {userStore.currentUser?.name}!</h2>
<p>You have {todoStore.todos.length} todos</p>
</div>
);
});
MobX with Async Actions
1. Flow for Async Operations
import { makeObservable, observable, action, flow } from 'mobx';
class ApiStore {
data = null;
isLoading = false;
error = null;
constructor() {
makeObservable(this, {
data: observable,
isLoading: observable,
error: observable,
fetchData: flow,
clearError: action
});
}
fetchData = flow(function* (id) {
this.isLoading = true;
this.error = null;
try {
const response = yield fetch(`/api/data/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch data');
}
this.data = yield response.json();
} catch (error) {
this.error = error.message;
} finally {
this.isLoading = false;
}
});
clearError() {
this.error = null;
}
}
2. Async/Await with runInAction
import { makeObservable, observable, action, runInAction } from 'mobx';
class AsyncStore {
posts = [];
isLoading = false;
constructor() {
makeObservable(this, {
posts: observable,
isLoading: observable,
fetchPosts: action
});
}
async fetchPosts() {
this.isLoading = true;
try {
const response = await fetch('/api/posts');
const posts = await response.json();
runInAction(() => {
this.posts = posts;
this.isLoading = false;
});
} catch (error) {
runInAction(() => {
this.isLoading = false;
});
console.error('Failed to fetch posts:', error);
}
}
}
Local Observable State
For simple local state, you can use useLocalObservable:
import React from 'react';
import { observer, useLocalObservable } from 'mobx-react-lite';
const Counter = observer(() => {
const state = useLocalObservable(() => ({
count: 0,
increment() {
this.count++;
},
decrement() {
this.count--;
},
get doubled() {
return this.count * 2;
}
}));
return (
<div>
<h3>Count: {state.count}</h3>
<p>Doubled: {state.doubled}</p>
<button onClick={state.increment}>+</button>
<button onClick={state.decrement}>-</button>
</div>
);
});
export default Counter;
MobX-State-Tree (MST)
For more structured state management, consider MobX-State-Tree:
npm install mobx-state-tree
import { types } from 'mobx-state-tree';
const Todo = types.model('Todo', {
id: types.string,
text: types.string,
completed: types.optional(types.boolean, false)
}).actions(self => ({
toggle() {
self.completed = !self.completed;
},
setText(text) {
self.text = text;
}
}));
const TodoStore = types.model('TodoStore', {
todos: types.array(Todo),
filter: types.optional(types.enumeration(['all', 'active', 'completed']), 'all')
}).views(self => ({
get filteredTodos() {
switch (self.filter) {
case 'completed':
return self.todos.filter(todo => todo.completed);
case 'active':
return self.todos.filter(todo => !todo.completed);
default:
return self.todos;
}
}
})).actions(self => ({
addTodo(text) {
self.todos.push({
id: Date.now().toString(),
text,
completed: false
});
},
setFilter(filter) {
self.filter = filter;
}
}));
// Create store instance
const store = TodoStore.create({ todos: [], filter: 'all' });
Debugging MobX
1. MobX Developer Tools
Install the MobX DevTools browser extension for debugging.
2. Logging State Changes
import { observe } from 'mobx';
// Observe changes to the entire store
observe(todoStore, (change) => {
console.log('Store changed:', change);
});
// Observe changes to a specific property
observe(todoStore, 'todos', (change) => {
console.log('Todos changed:', change);
});
3. Spy on MobX
import { spy } from 'mobx';
spy((event) => {
 if (event.type === 'action') {
  console.log(`Action: ${event.name}`, event.arguments);
 }
});
Best Practices
1. Structure Your Stores
- Keep stores focused on specific domains
- Use composition over inheritance
- Separate business logic from UI logic
2. Actions for All Mutations
// Good
class Store {
@observable items = [];
@action
addItem(item) {
this.items.push(item);
}
}
// Avoid
class Store {
@observable items = [];
someMethod() {
this.items.push(item); // Direct mutation without action
}
}
3. Use Computed Values
class ShoppingCart {
@observable items = [];
@computed
get total() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
@computed
get itemCount() {
return this.items.length;
}
}
4. Minimize Observer Components
Only wrap components that directly use observables:
// Good - only the component that uses the observable
const TodoItem = observer(({ todo }) => (
<li>{todo.text}</li>
));
const TodoList = ({ todos }) => (
<ul>
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
</ul>
);
// Less efficient - wrapping unnecessary components
const TodoList = observer(({ todos }) => (
<ul>
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
</ul>
));
MobX vs Redux Comparison
Aspect | MobX | Redux |
Learning Curve | Gentle | Steep |
Boilerplate | Minimal | Significant |
Performance | Automatic optimization | Manual optimization |
DevTools | Good | Excellent |
Type Safety | Good with TypeScript | Excellent with TypeScript |
Predictability | Good | Excellent |
Time Travel | Limited | Built-in |
Common Pitfalls
1. Forgetting to Use observer
// Wrong - component won't update
const TodoCount = ({ store }) => <div>{store.todos.length}</div>;
// Correct
const TodoCount = observer(({ store }) => <div>{store.todos.length}</div>);
2. Direct Array/Object Mutation
// Wrong
store.todos[0].completed = true;
// Correct
store.toggleTodo(store.todos[0].id);
3. Not Using Actions for State Changes
// Wrong
store.todos.push(newTodo);
// Correct
store.addTodo(newTodo);
Real-World Example: Shopping Cart
import { makeObservable, observable, action, computed } from 'mobx';
class Product {
constructor(id, name, price) {
this.id = id;
this.name = name;
this.price = price;
makeObservable(this, {
id: observable,
name: observable,
price: observable
});
}
}
class CartItem {
constructor(product, quantity = 1) {
this.product = product;
this.quantity = quantity;
makeObservable(this, {
quantity: observable,
product: observable,
updateQuantity: action,
subtotal: computed
});
}
updateQuantity(quantity) {
this.quantity = quantity;
}
get subtotal() {
return this.product.price * this.quantity;
}
}
class ShoppingCartStore {
items = [];
constructor() {
makeObservable(this, {
items: observable,
addProduct: action,
removeProduct: action,
updateQuantity: action,
clearCart: action,
total: computed,
itemCount: computed,
isEmpty: computed
});
}
addProduct(product, quantity = 1) {
const existingItem = this.items.find(item => item.product.id === product.id);
if (existingItem) {
existingItem.updateQuantity(existingItem.quantity + quantity);
} else {
this.items.push(new CartItem(product, quantity));
}
}
removeProduct(productId) {
this.items = this.items.filter(item => item.product.id !== productId);
}
updateQuantity(productId, quantity) {
const item = this.items.find(item => item.product.id === productId);
if (item) {
if (quantity <= 0) {
this.removeProduct(productId);
} else {
item.updateQuantity(quantity);
}
}
}
clearCart() {
this.items = [];
}
get total() {
return this.items.reduce((sum, item) => sum + item.subtotal, 0);
}
get itemCount() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
}
get isEmpty() {
return this.items.length === 0;
}
}
// Usage in React component
const ShoppingCart = observer(() => {
const cartStore = useStore().cartStore;
return (
<div className="shopping-cart">
<h2>Shopping Cart ({cartStore.itemCount} items)</h2>
{cartStore.isEmpty ? (
<p>Your cart is empty</p>
) : (
<>
{cartStore.items.map(item => (
<div key={item.product.id} className="cart-item">
<span>{item.product.name}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => cartStore.updateQuantity(
item.product.id,
parseInt(e.target.value)
)}
/>
<span>${item.subtotal.toFixed(2)}</span>
<button onClick={() => cartStore.removeProduct(item.product.id)}>
Remove
</button>
</div>
))}
<div className="cart-total">
<strong>Total: ${cartStore.total.toFixed(2)}</strong>
</div>
<button onClick={() => cartStore.clearCart()}>
Clear Cart
</button>
</>
)}
</div>
);
});
Conclusion
MobX provides a powerful and intuitive way to manage state in React applications. Its reactive programming model and minimal boilerplate make it an excellent choice for developers who want to focus on business logic rather than state management ceremony. While it may not provide the same level of predictability as Redux, its ease of use and automatic optimizations make it a compelling alternative for many applications.
In the next chapter, we’ll explore the broader React ecosystem and the tools and libraries that can enhance your React development experience.