← Back to DevBytes

MobX Performance: Optimization Techniques and Benchmarks

Understanding MobX Performance

MobX is a state management library that makes reactivity simple and transparent. It tracks which parts of your state are used by which observers (components, reactions, computed values) and automatically triggers re-renders or recalculations only when necessary. However, the default behavior—deeply observing every property, eagerly evaluating reactions, and re-rendering components on any observable change they consume—can lead to performance bottlenecks in large applications.

MobX performance optimization means tuning this reactivity to avoid unnecessary work: preventing redundant derivations, minimizing re-render scope, batching updates, and choosing the right observability level for each piece of state. When done correctly, MobX applications can handle thousands of observable values and hundreds of simultaneous updates without a noticeable frame drop.

Core Optimization Techniques

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

1. Use @observer (or observer()) Granularly

The observer wrapper converts a React component into a reactive component that re-renders when any observable it reads during render changes. Wrapping huge components that only need a tiny slice of state causes full re-renders. Instead, split your UI into small observer components so each subscribes only to the data it actually uses.

import { observer } from 'mobx-react-lite';

// ❌ Large component re-renders on any change to store
const BigDashboard = observer(({ store }) => (
  <div>
    <UserProfile user={store.user} />
    <Chart data={store.chartData} />
    <Notifications list={store.notifications} />
  </div>
));

// ✅ Extract granular observers
const UserProfile = observer(({ user }) => (
  <div>{user.name}</div>
));

const Chart = observer(({ data }) => (
  <canvas ref={drawChart(data)} />
));

const Notifications = observer(({ list }) => (
  <ul>{list.map(item => <li key={item.id}>{item.text}</li>)}</ul>
));

// Now parent doesn't even need to be observer if it just passes props
const BigDashboard = ({ store }) => (
  <div>
    <UserProfile user={store.user} />
    <Chart data={store.chartData} />
    <Notifications list={store.notifications} />
  </div>
);

2. Choose the Right Observable Depth

By default, makeAutoObservable applies deep observability recursively, which can be expensive for large, immutable-shaped objects that never change internally. Use observable.ref for values that are replaced wholesale (e.g., a deeply frozen object from an API), and observable.shallow for collections where only the reference to the array/map itself matters, not individual element properties.

import { makeAutoObservable, observable, runInAction } from 'mobx';

class Store {
  // Deep observable: every property inside user becomes observable
  user = { name: 'Alice', address: { city: 'NY' } };

  // Use observable.ref: the object reference is tracked, internals are not
  apiResponse = observable.ref({ huge: 'payload' });

  // observable.shallow: array reference is tracked, but item properties are not
  itemList = observable.shallow([]);

  constructor() {
    makeAutoObservable(this, {
      apiResponse: observable.ref,
      itemList: observable.shallow,
    });
  }

  fetchData() {
    // Replace apiResponse – only the reference change triggers observers
    runInAction(() => {
      this.apiResponse = { huge: 'new payload' };
    });
  }
}

3. Cache Derived State with computed

A computed value derives new information from existing observables and caches its result until the underlying observables change. This prevents expensive recalculations in every render or reaction cycle. Always prefer computed over manual getters or inline calculations inside components.

import { makeAutoObservable, computed } from 'mobx';

class TodoStore {
  todos = [];
  filter = 'all'; // 'all', 'completed', 'active'

  constructor() {
    makeAutoObservable(this);
  }

  // ❌ Without computed, the filter logic runs on every render/reaction
  get filteredTodos() {
    return this.todos.filter(todo => {
      if (this.filter === 'completed') return todo.done;
      if (this.filter === 'active') return !todo.done;
      return true;
    });
  }

  // ✅ computed caches and only re-evaluates when todos or filter change
  get filteredTodosComputed() {
    return this.todos.filter(todo => {
      if (this.filter === 'completed') return todo.done;
      if (this.filter === 'active') return !todo.done;
      return true;
    });
  }

  // Mark it as computed in constructor
  constructor() {
    makeAutoObservable(this, {
      filteredTodosComputed: computed
    });
  }
}

4. Batch Updates with Actions

Mutating multiple observables outside an action can trigger intermediate reactions and multiple re-renders. Wrapping them in runInAction or action ensures all changes are applied together before any derivations run.

import { runInAction, action } from 'mobx';

class Store {
  price = 0;
  quantity = 0;
  total = 0; // computed in another place

  // ❌ Multiple updates cause intermediate states
  updateOrder(newPrice, newQty) {
    this.price = newPrice;       // triggers reactions immediately
    this.quantity = newQty;      // triggers again
  }

  // ✅ Batched via runInAction
  updateOrderBatched(newPrice, newQty) {
    runInAction(() => {
      this.price = newPrice;
      this.quantity = newQty;
    });
  }

  // Alternatively, mark method as action
  @action
  updateOrderDecorated(newPrice, newQty) {
    this.price = newPrice;
    this.quantity = newQty;
  }
}

5. Tune reaction and autorun

autorun runs immediately and every time any observable it reads changes. reaction provides a separate tracking function and an effect function, and it does not run immediately by default. Use reaction for side effects that should not fire on initial setup. Also use delay to debounce rapid changes.

import { reaction, autorun } from 'mobx';

// autorun: runs immediately, good for logging, but can be wasteful
autorun(() => {
  console.log(`Total: ${store.total}`);
});

// reaction: fires only when the tracked expression changes
reaction(
  () => store.filter,               // track this
  (filter) => {                     // effect
    fetchFilteredData(filter);
  },
  {
    delay: 300,                     // debounce 300ms
    name: 'fetchOnFilterChange'     // for debugging
  }
);

6. Use when for One-off Conditions

when observes a predicate and runs the effect once when the predicate becomes true, then automatically disposes itself. It’s perfect for cleanup or one-time triggers without lingering subscriptions.

import { when } from 'mobx';

const disposer = when(
  () => store.user.isLoggedIn,
  () => {
    fetchUserPreferences(store.user.id);
  }
);
// later, if needed: disposer(); // cleanup manually, or it auto-disposes after first run

7. Avoid Direct Observable Mutations Outside Actions

MobX’s strict mode (enabled by default in mobx v6+) throws an error if you mutate observables outside actions. This prevents accidental performance-degrading patterns. Always keep mutations inside action or runInAction.

// Enforce strict mode (default in MobX 6+)
import { configure } from 'mobx';
configure({ enforceActions: 'observed' }); // or 'always'

8. Profile with trace and spy

When a component or computed value re-evaluates more often than expected, use trace inside a derivation to log why it ran. spy gives a global overview of all reactivity events.

import { trace, spy } from 'mobx';

// Inside a computed or reaction
computed get expensiveCalculation() {
  trace(); // logs what triggered this derivation
  return this.data.reduce((acc, val) => acc + val, 0);
}

// Global spy for debugging
spy(event => {
  if (event.type === 'reaction') {
    console.log(event.name, 'ran because', event.observing);
  }
});

9. Combine observer with React.memo

observer components already skip re-rendering when none of the observables they read change. But they can still re-render if their parent passes new non-observable props. Wrap the observer in React.memo to prevent re-renders due to shallow prop changes that don’t affect the observable subscription.

import { observer } from 'mobx-react-lite';
import React from 'react';

const ExpensiveItem = React.memo(observer(({ item }) => (
  <div>{item.title}</div>
)));

// Parent passes item from observable store; memo prevents re-render
// if parent re-renders due to unrelated state.

10. Structure State Trees Efficiently

Avoid deeply nested observable trees when the data is immutable after initial load. Normalize relational data (like entities with IDs) to reduce dependency chains. Use observable.ref for large arrays of objects where you only track the array reference.

class Store {
  // ❌ Deep observable for every post: heavy for 1000+ posts
  posts = [];

  // ✅ Shallow observable + normalized references
  postIds = observable.shallow([]);
  postMap = observable.map({}); // each post as plain object or observable.ref

  get postsList() {
    return this.postIds.map(id => this.postMap.get(id));
  }
}

Benchmarks: Measuring the Impact

To validate optimizations, set up a simple benchmark that measures render count and execution time during bulk updates. Below is a minimal React benchmark comparing deep observable vs shallow observable, and action batching vs unbatched updates.

import { makeAutoObservable, runInAction, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
import React, { useState, useEffect } from 'react';

// ---- Deep Observable Store (baseline) ----
class DeepStore {
  items = []; // each item becomes deeply observable
  constructor() { makeAutoObservable(this); }
  updateMany(count: number) {
    for (let i = 0; i < count; i++) {
      this.items.push({ id: i, value: Math.random() }); // each push triggers reaction
    }
  }
  updateManyBatched(count: number) {
    runInAction(() => {
      for (let i = 0; i < count; i++) {
        this.items.push({ id: i, value: Math.random() });
      }
    });
  }
}

// ---- Shallow Observable Store (optimized) ----
class ShallowStore {
  items = observable.shallow([]); // reference only
  constructor() { makeAutoObservable(this, { items: observable.shallow }); }
  updateMany(count: number) {
    const newItems = [...this.items];
    for (let i = 0; i < count; i++) newItems.push({ id: i, value: Math.random() });
    runInAction(() => { this.items = newItems; });
  }
}

const ItemList = observer(({ store }) => (
  <div>
    {store.items.map(item => <div key={item.id}>{item.value}</div>)}
  </div>
));

// Benchmark component (simplified)
const Benchmark = () => {
  const [deepStore] = useState(() => new DeepStore());
  const [shallowStore] = useState(() => new ShallowStore());
  const [renderCount, setRenderCount] = useState(0);

  useEffect(() => setRenderCount(c => c + 1), [deepStore.items.length, shallowStore.items.length]);

  const runTest = () => {
    const start = performance.now();
    deepStore.updateManyBatched(10000); // batched deep observable
    console.log('Deep batched time:', performance.now() - start);
  };

  return (
    <div>
      <button onClick={runTest}>Run Benchmark</button>
      <ItemList store={deepStore} />
      <p>Renders: {renderCount}</p>
    </div>
  );
};

Typical results on 10,000 items: deep observable unbatched can cause thousands of intermediate re-renders and take several seconds; deep batched reduces renders drastically; shallow observable replacement with a single reference change is even faster because it avoids per-item tracking overhead. Use such benchmarks to guide your refactoring.

Best Practices Summary

Conclusion

MobX gives you a finely tuned reactivity engine that can power even the most demanding user interfaces. The key to harnessing that power without introducing lag is understanding the cost of deep observability, the efficiency of batched actions, and the caching magic of computed. By applying the techniques above—granular observer components, shallow references, computed caching, action batching, and proper profiling—you can build applications that remain fast and predictable at any scale. Always measure before and after, and let the MobX devtools guide you toward a perfectly balanced reactive system.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles