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:
- Allowing you to structure your stores however you want (single store, multiple stores, nested stores)
- Minimizing boilerplate code through simple decorators and functions
- Enabling direct mutations of state instead of requiring immutable updates
- Automatically tracking and updating only the components that depend on changed data through its fine-grained dependency system
- Not enforcing strict architectural patterns, letting you adapt it to your needs
How MobX Differs from Others
-
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
-
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:
-
Precise Updates:
- Only re-renders components that actually depend on changed data
- Uses a fine-grained dependency tracking system
- Automatically batches updates
-
Virtual Derivations:
- Computed values are cached and only recalculated when dependencies change
- Prevents unnecessary recalculations
MobX Philosophy
MobX follows three core principles:
-
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
-
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:
- Objects become observable maps where property access is tracked
- Arrays become observable arrays that monitor element changes
- Primitives are wrapped in observable boxes that notify on value changes
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:
- Must be used to modify state (enforced in strict mode)
- Batch multiple state changes into a single update
- Make state changes explicit and traceable
- Can be bound methods, standalone functions, or async
Related action utilities:
runInAction
: Allows wrapping state modifications in an action scope, useful for async operations:async fetchTodos() { const todos = await api.getTodos(); runInAction(() => { this.todos = todos; }); }
action.bound
: Automatically binds action methods to class instanceflow
: Alternative to async/await that automatically wraps in actions
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:
- Logging state changes for debugging
- Syncing data with localStorage or external APIs
- Triggering UI notifications when specific state conditions are met
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:
- Individual stores (e.g., TodoStore) that handle specific domains of your application
- A RootStore that combines all individual stores and serves as the single source of truth
- 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:
- Creating a context using React’s createContext
- A custom hook (useStore) to easily access the store from components
- An initialization function that handles both client and server-side store creation
- Support for hydration of initial data, which is especially useful for SSR/SSG
// 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:
- Using useRef to maintain a stable reference to our store instance across re-renders
- Initializing the store with any initial state passed as props
- Using React’s Context.Provider to make the store available throughout the component tree
- Supporting hydration of server-side state through the initialState prop
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:
- Modifying the root
_app.tsx
file, which is Next.js’s main wrapper component - Passing any server-side state through pageProps.initialState
- Ensuring the store is properly initialized and hydrated for both client and server-side rendering
The _app.tsx
wrapper is crucial because:
- It runs on both server and client side
- It’s the perfect place to initialize global state
- It ensures all pages and components have access to the same store instance
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:
-
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
-
Data Flow Process:
- Server executes
getServerSideProps
orgetStaticProps
- 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
- Server executes
// 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
-
Serialization:
- Ensure your store data is serializable
- Don’t include methods or complex objects in the hydration data
-
State Rehydration:
- Always check if data exists before hydrating
- Handle loading states appropriately
-
Client-Side State:
- Keep client-specific state separate from SSR state
- Use
useEffect
for client-side-only initializations
-
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.