Skip to main content

Command Palette

Search for a command to run...

Debugging React Apps with MobX-State-Tree

Updated
8 min read
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:

  1. Install React DevTools extension

  2. Open DevTools → Profiler tab

  3. Click record → interact with app → stop

  4. 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:

  1. Is the component wrapped with observer?

  2. Is it reading from an MST observable?

  3. 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:

  1. Are you creating new objects/functions in render?

  2. Are you observing too much?

  3. 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:

  1. Log the API response

  2. Check the action that processes it

  3. 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

  1. Use TypeScript - Catch type errors before runtime

  2. Keep stores small - Easier to reason about and debug

  3. Use computed values in view - They're cached and easier to track

  4. Name your actions - Clear names make logs more useful

  5. 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: