Skip to content

Understanding MobX - A Comprehensive Guide

Posted on:March 9, 2025 at 02:45 PM

Understanding MobX: A Comprehensive Guide

Note: I wrote this guide as a note to myself while integrating MobX into a project, after reading through numerous dev.to posts and GitHub issues as at the time there was no “official implementation”. Now there is official implementation available here

1. What is MobX?

MobX is a state management library that makes it simple to connect reactive data with your UI. In this context, reactive means that when data changes, any code that depends on that data (like UI components) automatically updates - similar to how a spreadsheet formula recalculates when its input cells change. MobX implements fine-grained reactivity, meaning it precisely tracks which specific pieces of data each component uses and only updates those components when their exact dependencies change, rather than re-rendering entire component trees.

Unlike other state management solutions, MobX takes a straightforward and flexible approach by:

How MobX Differs from Others

  1. Compared to Redux:

    • MobX is more flexible and has less boilerplate
    • No need for action creators, reducers, or switch statements
    • Multiple stores are encouraged vs. Redux’s single store
    • Mutations are allowed (and encouraged) vs. Redux’s immutability
  2. Compared to Context API:

    • Better performance through granular updates
    • Built-in reactivity system
    • More powerful features like computed values
    • Better scalability for complex state

MobX’s Rendering Optimization

MobX optimizes rendering through several mechanisms:

  1. Precise Updates:

    • Only re-renders components that actually depend on changed data
    • Uses a fine-grained dependency tracking system
    • Automatically batches updates
  2. Virtual Derivations:

    • Computed values are cached and only recalculated when dependencies change
    • Prevents unnecessary recalculations

MobX Philosophy

MobX follows three core principles:

  1. Transparent Functional Reactive Programming (TFRP):

    • Everything that can be derived from the application state, should be derived automatically

    Sidenote on TFRP:

    Transparent Functional Reactive Programming (TFRP) is a programming paradigm that combines three key concepts:

    • Transparent: Data flow relationships are handled automatically by MobX without explicit definitions from developers
    • Functional: Pure computations derive values from source data
    • Reactive: Changes automatically propagate through the dependency graph, similar to Excel formulas

    Unlike traditional reactive programming which requires manual subscription management, TFRP in MobX makes the entire process seamless and automatic. This results in cleaner code with all the benefits of a reactive system.

    • Everything that can be derived from the application state, should be derived automatically
  2. Unidirectional Data Flow:

    • Actions → State → Derivations → Reactions

2. Core MobX Concepts

Sidenote on MobX vs RxJS Observables:

While both MobX and RxJS use the term “Observable”, they serve different purposes:

  • MobX Observables:
    • Focus on state management and UI reactivity
    • Automatically track dependencies and update UI
    • Simpler API focused on making state observable
    • Values are mutable and can be directly modified
    • Primarily used for component-based reactive state
  • RxJS Observables:
    • Focus on handling asynchronous data streams
    • Require explicit subscription management
    • Rich API for transforming data streams (operators)
    • Values are immutable; new streams created for transformations
    • Primarily used for event handling, async operations, and data streaming

MobX observables are better suited for state management in UI applications, while RxJS observables excel at handling complex async operations and event streams.

Observable State

Observable state represents the data that drives application. Any JavaScript data structure (objects, arrays, maps, primitives) can be made observable by using MobX decorators or functions like makeObservable(). When these observables change, MobX automatically tracks the changes and updates any computations or reactions that depend on them.

For example:

This makes it easy to create reactive state that automatically triggers UI updates when modified.

class Store {
    // Array to store todo items
    todos = [];
    // Flag to track loading state
    isLoading = false;

    constructor() {
        // Make this class observable by MobX
        makeObservable(this, {
            // Mark todos array as observable so MobX tracks changes
            todos: observable,
            // Mark loading flag as observable
            isLoading: observable,
            // Mark addTodo method as an action that can modify state
            addTodo: action,
            // Mark incompleteTodos as a computed value that derives from todos
            incompleteTodos: computed
        });
    }
}

Actions

Actions are methods that modify observable state in MobX. They are the only way to change observables to ensure predictable state changes. Actions help MobX batch updates and optimize rendering performance.

Key points about actions:

Related action utilities:

import { makeObservable, observable, action } from 'mobx';

class Store {
    todos = [];

    constructor() {
        makeObservable(this, {
            todos: observable,
            addTodo: action
        });
    }

    addTodo(text: string) {
        this.todos.push({ text, completed: false });
    }
}

Computed Values

Values that are automatically derived from your state.

import { makeObservable, observable, computed } from 'mobx';

class Store {
    todos = [];

    constructor() {
        makeObservable(this, {
            todos: observable,
            incompleteTodos: computed, // Automatically updates when todos array or todo.completed changes
            completedTodos: computed   // Automatically updates when todos array or todo.completed changes
        });
    }

    // Computed values are cached and only recalculated when their dependencies change
    // In this case, when todos array changes or when any todo's completed status changes
    get incompleteTodos() {
        return this.todos.filter(todo => !todo.completed);
    }

    // The computed value here prevents unnecessary recalculations
    // If you access completedTodos multiple times, it will return the cached value
    // unless the todos array or a todo's completed status has changed
    get completedTodos() {
        return this.todos.filter(todo => todo.completed);
    }
}

Reactions

Side effects that should run automatically when relevant data changes. Reactions are useful for handling side effects like logging, network requests, or UI updates. However, they should be used sparingly as they can make the flow of data harder to track and debug. Consider using explicit actions or computed values first, and only use reactions when you truly need automatic side effects. Common use cases include:

Remember that reactions add complexity to your application’s data flow, so use them judiciously.

import { autorun } from 'mobx';

autorun(() => {
    console.log(`Number of todos: ${store.todos.length}`);
});

3. Integrating MobX with Next.js (Pages Router) and SSR

Step 1: Project Setup

First, install the necessary dependencies:

npm install mobx mobx-react-lite

Step 2: Create Store Structure

When building a MobX application with Next.js, it’s important to set up a well-organized store structure. We’ll create a modular store architecture with the following key components:

  1. Individual stores (e.g., TodoStore) that handle specific domains of your application
  2. A RootStore that combines all individual stores and serves as the single source of truth
  3. Hydration support to handle server-side rendering (SSR) properly

Below is an example implementation of this store structure:

// stores/todo-store.ts
import { makeAutoObservable } from 'mobx';

export class TodoStore {
    todos = [];
    isLoading = false;

    constructor() {
        makeAutoObservable(this, {
            // Exclude hydrate from being an action
            hydrate: false
        });
    }

    addTodo(text: string) {
        this.todos.push({ text, completed: false });
    }

    toggleTodo(index: number) {
        this.todos[index].completed = !this.todos[index].completed;
    }

    get completedTodos() {
        return this.todos.filter(todo => todo.completed);
    }

    get pendingTodos() {
        return this.todos.filter(todo => !todo.completed);
    }

    // Not marked as action since it's used during initialization
    hydrate(data) {
        if (data) {
            this.todos = data.todos;
            this.isLoading = data.isLoading;
        }
    }
}

// stores/root-store.ts
import { makeAutoObservable } from 'mobx';
import { TodoStore } from './todo-store';

export class RootStore {
    todoStore: TodoStore;

    constructor() {
        this.todoStore = new TodoStore();
        makeAutoObservable(this, {
            // Exclude hydrate from being an action
            hydrate: false
        });
    }

    hydrate(data) {
        if (data) {
            this.todoStore.hydrate(data.todoStore);
        }
    }
}

Step 3: Create Store Context

In this step, we create a React Context to provide our MobX store throughout the application. This includes:

// stores/store-context.tsx
import { createContext, useContext } from 'react';
import { RootStore } from './root-store';

let store: RootStore;

export const StoreContext = createContext<RootStore | undefined>(undefined);

export function useStore() {
    const context = useContext(StoreContext);
    if (context === undefined) {
        throw new Error('useStore must be used within StoreProvider');
    }
    return context;
}

export function initializeStore(initialData?: any) {
    const _store = store ?? new RootStore();

    // If your page has Next.js data fetching methods that use a Mobx store, it will
    // get hydrated here
    if (initialData) {
        _store.hydrate(initialData);
    }

    // For SSG and SSR always create a new store
    if (typeof window === 'undefined') return _store;

    // Create the store once in the client
    if (!store) store = _store;

    return _store;
}

Step 4: Create Store Provider

In this step, we create a StoreProvider component that wraps our application and provides the MobX store to all child components. This includes:

The StoreProvider is a crucial piece that bridges our store setup with React’s component hierarchy, ensuring that all components have access to the same store instance.

// components/StoreProvider.tsx
import { useRef } from 'react';
import { StoreContext, initializeStore, RootStore } from '../stores';

export function StoreProvider({ children, initialState }) {
    const storeRef = useRef<RootStore>();
    if (!storeRef.current) {
        storeRef.current = initializeStore(initialState);
    }

    return (
        <StoreContext.Provider value={storeRef.current}>
            {children}
        </StoreContext.Provider>
    );
}

Step 5: Wrap App with Provider

Now that we have our StoreProvider component set up, we need to wrap our entire Next.js application with it to make the store globally available. This involves:

  1. Modifying the root _app.tsx file, which is Next.js’s main wrapper component
  2. Passing any server-side state through pageProps.initialState
  3. Ensuring the store is properly initialized and hydrated for both client and server-side rendering

The _app.tsx wrapper is crucial because:

Below you’ll see how we implement this wrapper in the _app.tsx file.

// pages/_app.tsx
import { StoreProvider } from '../components/StoreProvider';

function MyApp({ Component, pageProps }) {
    return (
        <StoreProvider initialState={pageProps.initialState}>
            <Component {...pageProps} />
        </StoreProvider>
    );
}

export default MyApp;

Step 6: Using the Store in Pages with SSR

When implementing server-side rendering (SSR) with MobX stores in Next.js, there are several key aspects to understand:

  1. Store Initialization Flow:

    • The store is first initialized on the server during SSR
    • Initial data is fetched and populated into the store
    • The store state is serialized and sent to the client
    • The client rehydrates the store with the server state
  2. Data Flow Process:

    • Server executes getServerSideProps or getStaticProps
    • Data is fetched and stored in a fresh store instance
    • Store state is extracted and passed through pageProps
    • Client receives the state and initializes store with it
    • Components render with hydrated data immediately
// pages/todos.tsx
import { observer } from 'mobx-react-lite';
import { useStore } from '../stores';

// observer is a higher-order component from mobx-react-lite that automatically
// re-renders the component when any observable data it uses changes.
// Without observer, the component won't react to MobX state updates.
const TodoPage = observer(() => {
    const store = useStore();

    return (
        <div>
            <h1>Todos ({store.todoStore.todos.length})</h1>
            {/* Your todo list UI */}
        </div>
    );
});

// SSR Data Fetching
export async function getServerSideProps() {
    // Fetch data and update store
    const response = await fetch('https://api.example.com/todos');
    const todos = await response.json();

    return {
        props: {
            initialState: {
                todoStore: {
                    todos,
                    isLoading: false
                }
            }
        }
    };
}

export default TodoPage;

Important SSR Considerations

  1. Serialization:

    • Ensure your store data is serializable
    • Don’t include methods or complex objects in the hydration data
  2. State Rehydration:

    • Always check if data exists before hydrating
    • Handle loading states appropriately
  3. Client-Side State:

    • Keep client-specific state separate from SSR state
    • Use useEffect for client-side-only initializations
  4. Performance:

    • Only hydrate necessary data
    • Use computed values for derived data instead of storing it

This setup provides a robust foundation for using MobX with Next.js, supporting both client-side state management and server-side rendering. The stores are properly hydrated during SSR and preserved during client-side navigation.