Debugging React Apps with MobX-State-Tree

From Redux to MST
If you're like me, coming from the Redux world, you'll soon find yourself missing the Redux DevTools. Being able to see your entire store at any moment, with time-travel debugging and a clear action log, is incredibly powerful. But as I learned more about MST, I started to appreciate its different approach.
MST feels lighter than Redux—no boilerplate for actions and reducers, and stores are scoped to specific data types rather than one giant global store. Compared to plain MobX, MST's structured model definitions are more elegant, with built-in type checking and snapshot-based time travel.
The trade-off? Debugging requires a different mindset. I tried several browser extensions, but they're either deprecated or don't work well with MST. There's no magic DevTools button. Instead, you'll need to instrument your code with MST's built-in debugging tools.
The good news? Once you learn these patterns, MST debugging becomes just as powerful—and in some ways, more flexible—than Redux DevTools.
1. The Three Built-in Debugging Tools
MST provides three powerful listeners that form the foundation of debugging:
onAction - Track What Happened
import { onAction } from 'mobx-state-tree';
onAction(rootStore, call => {
console.log('Action called:', {
name: call.name, // toggleTodo
path: call.path, // /todos/0
args: call.args // []
});
});
This tells you what action was called, where it was called, and what arguments were passed.
onPatch - Track What Changed
import { onPatch } from 'mobx-state-tree';
onPatch(rootStore, patch => {
console.log('State changed:', {
op: patch.op, // "replace", "add", or "remove"
path: patch.path, // "/todos/0/completed"
value: patch.value // true
});
});
This shows you the exact property that changed and its new value. This is usually the most useful for debugging.
onSnapshot - Track Full State
import { onSnapshot } from 'mobx-state-tree';
onSnapshot(rootStore, snapshot => {
console.log('Current state:', snapshot);
});
This gives you the entire state tree after each change. Use sparingly as it can be verbose.
2. Setting Up a Debugging Environment
Here's a practical setup for development:
// store/debugger.js
import { onAction, onPatch } from 'mobx-state-tree';
export function setupDebugger(store) {
if (process.env.NODE_ENV !== 'development') return;
// Track actions with grouping for readability
onAction(store, call => {
console.group(`🎬 ${call.name}`);
console.log('Path:', call.path);
console.log('Args:', call.args);
console.groupEnd();
});
// Track patches with colored output
onPatch(store, patch => {
const emoji = {
replace: '✏️',
add: '➕',
remove: '➖'
}[patch.op];
console.log(`${emoji} ${patch.path}`, patch.value);
});
// Make store accessible in console
window.store = store;
console.log('💡 Store available as window.store');
}
// store/index.js
import { setupDebugger } from './debugger';
export function createRootStore() {
const store = RootStore.create({ /* ... */ });
setupDebugger(store);
return store;
}
Now every action and state change is logged automatically!
3. Debugging Component Re-renders
One common issue: "Why is my component re-rendering?"
Use React DevTools Profiler
First, use React DevTools to see what is re-rendering:
Install React DevTools extension
Open DevTools → Profiler tab
Click record → interact with app → stop
See which components rendered and why
Add trace() to See Observables
Use MobX's trace() to see what observables a component is tracking:
import { observer } from 'mobx-react-lite';
import { trace } from 'mobx';
const TodoList = observer(() => {
const { todoStore } = useStores();
// Add this temporarily
trace();
return (
<ul>
{todoStore.todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
});
When this component re-renders, you'll see in the console which observable triggered it.
Common Re-render Issues
Problem 1: Missing observer wrapper
// ❌ Won't update when todo changes
const TodoItem = ({ todo }) => {
return <div>{todo.title}</div>;
};
// ✅ Will update
const TodoItem = observer(({ todo }) => {
return <div>{todo.title}</div>;
});
Problem 2: Over-observing
// ❌ Re-renders when ANY todo changes
const TodoList = observer(() => {
const { todoStore } = useStores();
return (
<div>
Completed: {todoStore.todos.filter(t => t.completed).length}
</div>
);
});
// ✅ Only re-renders when completed count changes
const TodoStore = types.model({
todos: types.array(Todo),
}).views(self => ({
get completedCount() {
return self.todos.filter(t => t.completed).length;
}
}));
const TodoList = observer(() => {
const { todoStore } = useStores();
return <div>Completed: {todoStore.completedCount}</div>;
});
Move calculations to computed values (views) in your store!
4. Time Travel Debugging
One of MST's superpowers is snapshots. Here's how to implement undo/redo:
import { applySnapshot, getSnapshot } from 'mobx-state-tree';
class TimeTravel {
constructor(store) {
this.store = store;
this.history = [getSnapshot(store)];
this.currentIndex = 0;
onSnapshot(store, snapshot => {
// Remove any "future" states if we went back
this.history.splice(this.currentIndex + 1);
this.history.push(snapshot);
this.currentIndex = this.history.length - 1;
});
}
undo() {
if (this.currentIndex > 0) {
this.currentIndex--;
applySnapshot(this.store, this.history[this.currentIndex]);
console.log('⏪ Undo');
}
}
redo() {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
applySnapshot(this.store, this.history[this.currentIndex]);
console.log('⏩ Redo');
}
}
}
// Usage
const timeTravel = new TimeTravel(rootStore);
window.undo = () => timeTravel.undo();
window.redo = () => timeTravel.redo();
Now you can type undo() or redo() in the console to step through state changes!
5. Debugging Async Actions
Async actions can be particularly tricky. Here's how to add proper logging:
const TodoStore = types.model({
todos: types.array(Todo),
isLoading: false,
error: null,
}).actions(self => ({
async fetchTodos() {
console.log('📡 Fetching todos...');
self.isLoading = true;
self.error = null;
try {
const response = await fetch('/api/todos');
const data = await response.json();
console.log('✅ Fetched:', data.length, 'todos');
self.todos = data;
} catch (err) {
console.error('❌ Fetch failed:', err);
self.error = err.message;
} finally {
self.isLoading = false;
console.log('📡 Fetch complete');
}
}
}));
Use try-catch blocks and log at each stage to see where things break.
6. Production Debugging
You can't use console.log in production, but you can store logs:
class MSTLogger {
constructor(store, maxLogs = 100) {
this.logs = [];
onAction(store, call => {
this.addLog({
type: 'action',
name: call.name,
path: call.path,
args: call.args,
timestamp: Date.now()
});
});
onPatch(store, patch => {
this.addLog({
type: 'patch',
op: patch.op,
path: patch.path,
value: patch.value,
timestamp: Date.now()
});
});
}
addLog(log) {
this.logs.push(log);
if (this.logs.length > 100) {
this.logs.shift(); // Keep last 100
}
}
export() {
return JSON.stringify(this.logs, null, 2);
}
sendToErrorTracking() {
// Send to Sentry, LogRocket, etc.
if (window.Sentry) {
window.Sentry.captureMessage('MST Logs', {
extra: { logs: this.logs }
});
}
}
}
// Usage
const logger = new MSTLogger(rootStore);
window.exportLogs = () => logger.export();
When a user reports a bug, ask them to run exportLogs() in the console and send you the output!
7. MST DevTools
For the best debugging experience, use the MST Inspector:
npm install --save-dev mobx-state-tree-inspector
import { makeInspectable } from 'mobx-state-tree-inspector';
if (process.env.NODE_ENV === 'development') {
makeInspectable(rootStore);
}
This adds a visual tree inspector to Chrome DevTools showing your entire state tree and all changes in real-time.
8. Common Debugging Scenarios
"My component isn't updating"
Checklist:
Is the component wrapped with
observer?Is it reading from an MST observable?
Is the data actually changing? (Add a patch listener)
// Add this temporarily
onPatch(rootStore.todos, patch => {
console.log('Todo changed:', patch);
});
"Too many re-renders"
Checklist:
Are you creating new objects/functions in render?
Are you observing too much?
Move calculations to computed values
// ❌ Bad - creates new function every render
<button onClick={() => store.addTodo(text)}>Add</button>
// ✅ Good - stable reference
const handleAdd = () => store.addTodo(text);
<button onClick={handleAdd}>Add</button>
"State is wrong after API call"
Debug:
Log the API response
Check the action that processes it
Verify the model types match the data
async fetchTodos() {
const response = await fetch('/api/todos');
const data = await response.json();
console.log('API returned:', data);
console.log('Current todos:', getSnapshot(self.todos));
self.todos = data;
console.log('New todos:', getSnapshot(self.todos));
}
Note: It’s recommended to use MST’s flow for async calls to keep the transaction atomic.
import { types, flow } from "mobx-state-tree";
const UserStore = types
.model("UserStore", {
users: types.array(types.frozen()),
// Track the lifecycle of the request
status: types.optional(
types.enumeration(["idle", "pending", "done", "error"]),
"idle"
)
})
.actions((self) => ({
// Use a generator function (note the *)
fetchUsers: flow(function* () {
self.status = "pending";
try {
// Use yield instead of await
const response = yield fetch("https://api.example.com/users");
const data = yield response.json();
// MST automatically wraps this update in an action context
self.users = data;
self.status = "done";
} catch (error) {
console.error("Failed to fetch users:", error);
self.status = "error";
}
})
}));
9. Best Practices
Use TypeScript - Catch type errors before runtime
Keep stores small - Easier to reason about and debug
Use computed values in view - They're cached and easier to track
Name your actions - Clear names make logs more useful
One action per user interaction - Makes debugging straightforward
10. Quick Debug Snippet
Add this to your store for instant debugging:
.views(self => ({
get debug() {
return {
snapshot: getSnapshot(self),
// Add any useful computed data
todoCount: self.todos.length,
completedCount: self.todos.filter(t => t.completed).length,
};
}
}));
// In console
console.table(rootStore.debug);
Conclusion
Debugging MST apps is different from Redux, but once you know the tools, it's actually quite powerful. The key insights:
Use onPatch to see what changed
Use onAction to see what caused it
Use trace() to debug component re-renders
Use snapshots for time travel
Set up logging in development mode
Use flow() for async calls
With these tools in your belt, you'll spend less time debugging and more time building features. Happy debugging!
Resources:



