Jobnik

Dss

MobX Masterclass: Building Highly Reactive Applications with React

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.

MobX Data Flow Diagram

Key Concepts in MobX: Your Building Blocks

To master MobX, let’s understand its core concepts:

  1. 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.
  2. 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.
  3. 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.
  4. 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, and when.

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 made observable, meaning MobX will track its changes.
  • increment and decrement are actions, clearly defining how count can be modified.
  • doubleCount is a computed value that automatically updates when count 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.

MobX Observable Collections

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: an expression (which returns the data you want to react to) and an effect (which runs when the expression’s result changes). The effect only reacts to changes in the data returned by the expression, not to data accessed directly within the effect itself.
  • when(predicate, effect): Runs an effect function only once, when a predicate function (which observes observables) returns true. After the effect 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 actions 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, and when judiciously for side effects. For rendering the UI, simply wrapping your components in observer 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!

Leave a Reply

Your email address will not be published. Required fields are marked *