Welcome to the MobX Masterclass! In this comprehensive tutorial, we’ll dive deep into MobX, a powerful, simple, and scalable state management library that offers a reactive approach to building robust applications with React. If you’ve found Redux a bit too verbose or prescriptive, MobX might just be the breath of fresh air you’re looking for. It embraces simplicity and automates state updates, allowing you to write less boilerplate and focus on your application’s core logic.
Why MobX? The Power of Reactivity
MobX stands out by making your application’s state observable. This means that any part of your UI that *observes* a piece of state will automatically re-render when that state changes, and only that part. This fine-grained reactivity is incredibly efficient and leads to highly performant applications.
Unlike Redux, which often requires you to manually dispatch actions and write reducers, MobX leverages observable data. When data changes, MobX automatically propagates those changes to all components that are observing it. This “just works” philosophy significantly reduces the mental overhead and boilerplate code.
Key Concepts in MobX: Your Building Blocks
To master MobX, let’s understand its core concepts:
- Observables: These are the heart of MobX. Observables are values, objects, arrays, or maps that MobX can react to. When an observable changes, MobX automatically detects it and notifies all “reactions” that depend on it. Think of them as the pieces of your state that MobX is actively watching.
- Actions: Actions are dedicated functions that modify observables. While MobX doesn’t strictly enforce actions for state mutations (you *can* modify observables directly), it’s highly recommended to use actions. Actions batch changes, improve debugging by providing clear intent, and are essential for asynchronous operations.
- Computed Values: These are values that can be derived from existing observables. They are like a spreadsheet formula: they automatically re-evaluate only when the observables they depend on change. Computed values are powerful for memoization and ensuring derived data is always up-to-date and efficient.
- Reactions: Reactions are automatic side effects that run whenever the observables they depend on change. These are primarily used for bridging imperative programming with reactive programming, such as updating the DOM, logging, or making network requests. Common reactions include
autorun
,reaction
, andwhen
.
Setting Up Your MobX Project with React
Let’s start with a basic React application and integrate MobX. We’ll use Create React App for simplicity.
npx create-react-app mobx-tutorial
cd mobx-tutorial
npm install mobx mobx-react-lite
mobx
is the core library, and mobx-react-lite
provides React bindings that are optimized for functional components and hooks.
Building Our First Observable State
Let’s create a simple counter application to demonstrate observables and actions.
Create a file named src/stores/CounterStore.js
:
// src/stores/CounterStore.js
import { makeObservable, observable, action, computed } from 'mobx';
class CounterStore {
count = 0;
constructor() {
makeObservable(this, {
count: observable,
increment: action,
decrement: action,
doubleCount: computed
});
}
increment() {
this.count++;
}
decrement() {
this.count--;
}
get doubleCount() {
return this.count * 2;
}
}
export const counterStore = new CounterStore();
In this store:
count
is madeobservable
, meaning MobX will track its changes.increment
anddecrement
areaction
s, clearly defining howcount
can be modified.doubleCount
is acomputed
value that automatically updates whencount
changes.
Connecting MobX to React Components
Now, let’s use our CounterStore
in a React component. We’ll use the observer
HOC (Higher-Order Component) or the useObserver
hook from mobx-react-lite
.
Modify src/App.js
:
// src/App.js
import React from 'react';
import { observer } from 'mobx-react-lite'; // Important: use observer for MobX reactivity
import { counterStore } from './stores/CounterStore';
const App = observer(() => {
return (
MobX Counter
Current Count: {counterStore.count}
Double Count: {counterStore.doubleCount}
);
// Alternatively, for functional components you can use useLocalStore or useObserver
// const App = () => {
// const store = useLocalStore(() => ({ count: 0, increment() { store.count++ } }));
// return useObserver(() => (
//
// {store.count}
//
//
// ));
// }
});
export default App;
By wrapping our functional component with observer
, we tell MobX that this component needs to react to changes in any observables it accesses. When counterStore.count
changes, only this component (and any other observers of count
) will re-render, ensuring optimal performance.
Working with Observable Collections (Arrays and Maps)
MobX makes handling collections incredibly easy. Let’s create a simple Todo application.
Create src/stores/TodoStore.js
:
// src/stores/TodoStore.js
import { makeObservable, observable, action, computed } from 'mobx';
class TodoStore {
todos = []; // This will be an observable array
constructor() {
makeObservable(this, {
todos: observable,
addTodo: action,
toggleTodo: action,
completedTodosCount: computed
});
}
addTodo(text) {
this.todos.push({
id: Date.now(),
text,
completed: false
});
}
toggleTodo(id) {
const todo = this.todos.find(todo => todo.id === id);
if (todo) {
todo.completed = !todo.completed; // MobX tracks changes to individual items in observable arrays
}
}
get completedTodosCount() {
return this.todos.filter(todo => todo.completed).length;
}
}
export const todoStore = new TodoStore();
Notice how straightforward it is to modify the todos
array and its items. MobX automatically tracks mutations like push
, splice
, and direct property assignments to array elements.
Now, update src/App.js
to include the Todo list:
// src/App.js - Updated to include Todo list
import React, { useState } from 'react';
import { observer } from 'mobx-react-lite';
import { todoStore } from './stores/TodoStore';
const TodoApp = observer(() => {
const [newTodoText, setNewTodoText] = useState('');
const handleAddTodo = () => {
if (newTodoText.trim()) {
todoStore.addTodo(newTodoText);
setNewTodoText('');
}
};
return (
MobX Todo List
setNewTodoText(e.target.value)}
placeholder="Add a new todo"
style={{ padding: '8px', marginRight: '10px', width: '300px' }}
/>
{todoStore.todos.map(todo => (
-
todoStore.toggleTodo(todo.id)} style={{ cursor: 'pointer' }}>
{todo.text}
{todo.completed ? '✅' : '⏳'}
))}
Completed Todos: {todoStore.completedTodosCount} / {todoStore.todos.length}
);
});
// You can combine multiple apps or components in App.js as needed
const App = () => (
<>
{/* If you want to keep the counter */}
>
);
export default App;
Asynchronous Actions with MobX
Handling asynchronous operations like fetching data from an API is straightforward with MobX actions. Let’s simulate fetching todos.
Update src/stores/TodoStore.js
:
// src/stores/TodoStore.js - Updated for async action
import { makeObservable, observable, action, computed, runInAction } from 'mobx';
class TodoStore {
todos = [];
isLoading = false; // New observable for loading state
constructor() {
makeObservable(this, {
todos: observable,
isLoading: observable, // Make isLoading observable
addTodo: action,
toggleTodo: action,
fetchTodos: action, // Declare fetchTodos as an action
completedTodosCount: computed
});
}
addTodo(text) {
this.todos.push({
id: Date.now(),
text,
completed: false
});
}
toggleTodo(id) {
const todo = this.todos.find(todo => todo.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}
// Async action to fetch todos
async fetchTodos() {
this.isLoading = true; // Set loading to true immediately
try {
// Simulate API call
const response = await new Promise(resolve => setTimeout(() => {
resolve([
{ id: 1, text: 'Learn MobX', completed: true },
{ id: 2, text: 'Build a MobX App', completed: false },
{ id: 3, text: 'Deploy to Production', completed: false }
]);
}, 1000));
// Use runInAction to ensure state mutations outside of an action's direct scope are still batched
runInAction(() => {
this.todos = response;
this.isLoading = false;
});
} catch (error) {
runInAction(() => {
console.error("Failed to fetch todos", error);
this.isLoading = false;
});
}
}
get completedTodosCount() {
return this.todos.filter(todo => todo.completed).length;
}
}
export const todoStore = new TodoStore();
And update src/App.js
to call fetchTodos
and display loading state:
// src/App.js - Further updated for async todo fetching
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { todoStore } from './stores/TodoStore';
const TodoApp = observer(() => {
const [newTodoText, setNewTodoText] = useState('');
// Fetch todos on component mount
useEffect(() => {
todoStore.fetchTodos();
}, []);
const handleAddTodo = () => {
if (newTodoText.trim()) {
todoStore.addTodo(newTodoText);
setNewTodoText('');
}
};
return (
MobX Todo List
setNewTodoText(e.target.value)}
placeholder="Add a new todo"
style={{ padding: '8px', marginRight: '10px', width: '300px' }}
/>
{todoStore.isLoading ? (
Loading todos...
) : (
{todoStore.todos.map(todo => (
-
todoStore.toggleTodo(todo.id)} style={{ cursor: 'pointer' }}>
{todo.text}
{todo.completed ? '✅' : '⏳'}
))}
)}
Completed Todos: {todoStore.completedTodosCount} / {todoStore.todos.length}
);
});
const App = () => (
<>
>
);
export default App;
Here, we introduce an isLoading
observable. The fetchTodos
action sets isLoading
to true
before the async operation and back to false
afterwards. The useEffect
hook ensures fetchTodos
is called when the component mounts. Notice the use of runInAction
for mutations that happen *after* an await
, ensuring they are still part of a MobX action.
Reactions: autorun
, reaction
, and when
MobX provides several utilities to create reactions:
autorun(callback)
: Runs a function once and then re-runs it whenever any observable used inside the function changes. It’s useful for logging, debugging, or ensuring a piece of imperative code stays in sync with your state.reaction(expression, effect)
: Gives you more fine-grained control. It takes two functions: anexpression
(which returns the data you want to react to) and aneffect
(which runs when the expression’s result changes). Theeffect
only reacts to changes in the data returned by theexpression
, not to data accessed directly within theeffect
itself.when(predicate, effect)
: Runs aneffect
function only once, when apredicate
function (which observes observables) returnstrue
. After theeffect
runs, the reaction is disposed of. Ideal for one-time side effects based on state conditions.
Let’s add a simple autorun
to our TodoStore
for logging:
// src/stores/TodoStore.js - Add autorun for logging
import { makeObservable, observable, action, computed, runInAction, autorun } from 'mobx';
class TodoStore {
// ... (rest of your store code)
constructor() {
makeObservable(this, {
todos: observable,
isLoading: observable,
addTodo: action,
toggleTodo: action,
fetchTodos: action,
completedTodosCount: computed
});
// Autorun to log changes in todos
autorun(() => {
console.log(`Current number of todos: ${this.todos.length}`);
console.log(`Completed todos: ${this.completedTodosCount}`);
});
}
// ... (rest of your methods)
}
export const todoStore = new TodoStore();
Now, whenever this.todos.length
or this.completedTodosCount
changes, the autorun
function will execute, logging the updated values to the console.
Structuring Your MobX Stores
For larger applications, it’s common to have multiple stores, each managing a specific domain of your application’s state (e.g., UserStore, ProductStore, CartStore). You can then combine them or pass them down using React Context API.
A common pattern for providing stores to components is using React Context:
Create src/stores/RootStore.js
:
// src/stores/RootStore.js
import { createContext, useContext } from 'react';
import { counterStore } from './CounterStore';
import { todoStore } from './TodoStore';
class RootStore {
constructor() {
this.counterStore = counterStore;
this.todoStore = todoStore;
}
}
const rootStore = new RootStore();
const RootStoreContext = createContext(rootStore);
export const useStores = () => useContext(RootStoreContext);
export const StoreProvider = ({ children }) => (
{children}
);
Then, wrap your App with the StoreProvider
in src/index.js
:
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { StoreProvider } from './stores/RootStore'; // Import StoreProvider
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
);
And finally, use the useStores
hook in your components:
// src/App.js - Updated to use useStores hook
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react-lite';
import { useStores } from './stores/RootStore'; // Import useStores
const TodoApp = observer(() => {
const { todoStore } = useStores(); // Get todoStore from context
const [newTodoText, setNewTodoText] = useState('');
useEffect(() => {
todoStore.fetchTodos();
}, [todoStore]); // Add todoStore to dependency array
const handleAddTodo = () => {
if (newTodoText.trim()) {
todoStore.addTodo(newTodoText);
setNewTodoText('');
}
};
return (
{/* ... rest of the TodoApp component ... */}
MobX Todo List
setNewTodoText(e.target.value)}
placeholder="Add a new todo"
style={{ padding: '8px', marginRight: '10px', width: '300px' }}
/>
{todoStore.isLoading ? (
Loading todos...
) : (
{todoStore.todos.map(todo => (
-
todoStore.toggleTodo(todo.id)} style={{ cursor: 'pointer' }}>
{todo.text}
{todo.completed ? '✅' : '⏳'}
))}
)}
Completed Todos: {todoStore.completedTodosCount} / {todoStore.todos.length}
);
});
const App = () => (
<>
>
);
export default App;
This pattern provides a clean way to access your stores throughout your component tree without prop drilling.
MobX Best Practices and Tips
- Keep Stores Flat: While you can have nested observables, try to keep your observable data structures relatively flat. This makes them easier to reason about and observe.
- Use Actions for Mutations: Although MobX allows direct mutations, using
action
s for all state modifications is a strong recommendation. It batches updates, improves debugging, and is crucial for asynchronous flows. - Leverage Computed Values: Don’t re-calculate derived data in your components. Use computed values in your stores for efficient memoization.
- Be Mindful of Reactions: Use
autorun
,reaction
, andwhen
judiciously for side effects. For rendering the UI, simply wrapping your components inobserver
is usually sufficient. - Use
mobx-react-lite
for Functional Components: It’s a lightweight and performant package specifically designed for functional React components with MobX. - Developer Tools: Use the MobX Developer Tools for Chrome/Firefox to inspect your observable state, trace reactions, and debug your application more effectively.
Conclusion
MobX offers a compelling alternative for state management in React, emphasizing simplicity, flexibility, and powerful reactivity. By understanding its core concepts—observables, actions, computed values, and reactions—you can build highly performant and maintainable applications with less boilerplate code.
While Redux provides a strict, explicit data flow, MobX embraces a more implicit, “just works” approach that can lead to faster development cycles and a more intuitive coding experience, especially for those who prefer an object-oriented or reactive programming paradigm. Experiment with it, and you might find it to be your new go-to state management solution for React!
Happy coding!