MobX Complete Guide | Simple Reactive State Management

MobX Complete Guide | Simple Reactive State Management

이 글의 핵심

MobX is a simple, scalable state management solution that makes state management transparent through reactive programming. It automatically tracks dependencies and updates components.

Introduction

MobX is a battle-tested library that makes state management simple and scalable by transparently applying functional reactive programming (FRP). The philosophy is simple: anything that can be derived from the application state, should be derived automatically.

The Problem

Traditional state management:

// Redux: Too much boilerplate
const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
};

// Context: Re-renders entire tree
const [state, setState] = useState({ count: 0, name: '' });
// Changing count re-renders all consumers

The Solution

With MobX:

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

class Store {
  count = 0;
  
  constructor() {
    makeObservable(this, {
      count: observable,
      increment: action,
    });
  }
  
  increment() {
    this.count++;
  }
}

1. Installation

npm install mobx mobx-react-lite

2. Core Concepts

Observable State

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

class TodoStore {
  todos = [];
  
  constructor() {
    makeObservable(this, {
      todos: observable,
      addTodo: action,
    });
  }
  
  addTodo(text) {
    this.todos.push({ id: Date.now(), text, done: false });
  }
}

With makeAutoObservable (Simpler)

import { makeAutoObservable } from 'mobx';

class TodoStore {
  todos = [];
  
  constructor() {
    makeAutoObservable(this); // Automatically marks everything
  }
  
  addTodo(text) {
    this.todos.push({ id: Date.now(), text, done: false });
  }
  
  get completedCount() {
    return this.todos.filter(t => t.done).length;
  }
}

3. React Integration

observer Component

import { observer } from 'mobx-react-lite';
import { todoStore } from './stores';

const TodoList = observer(() => {
  return (
    <div>
      {todoStore.todos.map(todo => (
        <div key={todo.id}>{todo.text}</div>
      ))}
    </div>
  );
});

Creating Store Context

// stores/TodoStore.ts
import { makeAutoObservable } from 'mobx';

class TodoStore {
  todos = [];
  
  constructor() {
    makeAutoObservable(this);
  }
  
  addTodo(text: string) {
    this.todos.push({ id: Date.now(), text, done: false });
  }
  
  toggleTodo(id: number) {
    const todo = this.todos.find(t => t.id === id);
    if (todo) todo.done = !todo.done;
  }
}

export const todoStore = new TodoStore();
// App.tsx
import { observer } from 'mobx-react-lite';
import { todoStore } from './stores/TodoStore';

const App = observer(() => {
  const [text, setText] = useState('');
  
  const handleAdd = () => {
    todoStore.addTodo(text);
    setText('');
  };
  
  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAdd}>Add</button>
      
      <ul>
        {todoStore.todos.map(todo => (
          <li key={todo.id} onClick={() => todoStore.toggleTodo(todo.id)}>
            {todo.done ? '✓' : '○'} {todo.text}
          </li>
        ))}
      </ul>
    </div>
  );
});

4. Computed Values

import { makeAutoObservable, computed } from 'mobx';

class CartStore {
  items = [];
  
  constructor() {
    makeAutoObservable(this);
  }
  
  get total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
  
  get itemCount() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }
}

Computed values are cached:

const cart = new CartStore();
cart.items.push({ price: 10, quantity: 2 });

console.log(cart.total); // Computes: 20
console.log(cart.total); // Cached: 20 (doesn't recompute)

cart.items[0].quantity = 3;
console.log(cart.total); // Recomputes: 30

5. Actions

Synchronous Actions

import { makeAutoObservable, action } from 'mobx';

class UserStore {
  users = [];
  
  constructor() {
    makeAutoObservable(this);
  }
  
  addUser(user) {
    this.users.push(user);
  }
  
  removeUser(id) {
    this.users = this.users.filter(u => u.id !== id);
  }
}

Async Actions (runInAction)

import { makeAutoObservable, runInAction } from 'mobx';

class UserStore {
  users = [];
  loading = false;
  
  constructor() {
    makeAutoObservable(this);
  }
  
  async fetchUsers() {
    this.loading = true;
    
    try {
      const res = await fetch('/api/users');
      const users = await res.json();
      
      runInAction(() => {
        this.users = users;
        this.loading = false;
      });
    } catch (error) {
      runInAction(() => {
        this.loading = false;
      });
    }
  }
}

flow (Alternative for Async)

import { makeAutoObservable, flow } from 'mobx';

class UserStore {
  users = [];
  loading = false;
  
  constructor() {
    makeAutoObservable(this, {
      fetchUsers: flow,
    });
  }
  
  *fetchUsers() {
    this.loading = true;
    
    try {
      const res = yield fetch('/api/users');
      const users = yield res.json();
      this.users = users;
    } finally {
      this.loading = false;
    }
  }
}

6. Reactions

autorun

import { autorun } from 'mobx';

const store = new TodoStore();

autorun(() => {
  console.log('Total todos:', store.todos.length);
});

store.addTodo('Learn MobX'); // Logs: Total todos: 1

reaction

import { reaction } from 'mobx';

reaction(
  () => store.todos.length, // What to track
  (count) => {
    console.log('Todo count changed:', count);
  }
);

when

import { when } from 'mobx';

when(
  () => store.todos.length > 5,
  () => console.log('More than 5 todos!')
);

7. Real-World Example: Shopping Cart

// stores/CartStore.ts
import { makeAutoObservable } from 'mobx';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

class CartStore {
  items: CartItem[] = [];
  
  constructor() {
    makeAutoObservable(this);
  }
  
  addItem(product: { id: number; name: string; price: number }) {
    const existing = this.items.find(item => item.id === product.id);
    
    if (existing) {
      existing.quantity++;
    } else {
      this.items.push({ ...product, quantity: 1 });
    }
  }
  
  removeItem(id: number) {
    this.items = this.items.filter(item => item.id !== id);
  }
  
  updateQuantity(id: number, quantity: number) {
    const item = this.items.find(item => item.id === id);
    if (item) {
      item.quantity = quantity;
    }
  }
  
  get total() {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
  
  get itemCount() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }
  
  clear() {
    this.items = [];
  }
}

export const cartStore = new CartStore();
// components/Cart.tsx
import { observer } from 'mobx-react-lite';
import { cartStore } from '../stores/CartStore';

export const Cart = observer(() => {
  return (
    <div>
      <h2>Shopping Cart ({cartStore.itemCount} items)</h2>
      
      {cartStore.items.map(item => (
        <div key={item.id}>
          <span>{item.name}</span>
          <span>${item.price}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={e => cartStore.updateQuantity(item.id, +e.target.value)}
          />
          <button onClick={() => cartStore.removeItem(item.id)}>Remove</button>
        </div>
      ))}
      
      <div>Total: ${cartStore.total.toFixed(2)}</div>
      <button onClick={() => cartStore.clear()}>Clear Cart</button>
    </div>
  );
});

8. Multiple Stores

// stores/RootStore.ts
import { makeAutoObservable } from 'mobx';
import { UserStore } from './UserStore';
import { CartStore } from './CartStore';

class RootStore {
  userStore: UserStore;
  cartStore: CartStore;
  
  constructor() {
    this.userStore = new UserStore(this);
    this.cartStore = new CartStore(this);
    makeAutoObservable(this);
  }
}

export const rootStore = new RootStore();
// Using React Context
import { createContext, useContext } from 'react';
import { rootStore } from './stores/RootStore';

const StoreContext = createContext(rootStore);

export const useStore = () => useContext(StoreContext);

// In component
const { userStore, cartStore } = useStore();

9. Best Practices

1. Use makeAutoObservable

// Good
class Store {
  count = 0;
  constructor() {
    makeAutoObservable(this);
  }
}

// Verbose (avoid unless needed)
class Store {
  count = 0;
  constructor() {
    makeObservable(this, {
      count: observable,
      increment: action,
    });
  }
}

2. Use runInAction for Async

// Good
async fetchData() {
  const data = await api.getData();
  runInAction(() => {
    this.data = data;
  });
}

// Bad: modifying state outside action
async fetchData() {
  const data = await api.getData();
  this.data = data; // Warning!
}

3. Avoid Mutating Arrays Directly

// Good
addItem(item) {
  this.items.push(item);
}

removeItem(id) {
  this.items = this.items.filter(i => i.id !== id);
}

// Also good (with MobX)
removeItem(id) {
  this.items.splice(
    this.items.findIndex(i => i.id === id),
    1
  );
}

10. Performance Tips

1. Use observer on Component Level

// Good: Only this component re-renders
const TodoItem = observer(({ todo }) => {
  return <div>{todo.text}</div>;
});

// Bad: Parent re-renders all children
const TodoList = observer(() => {
  return store.todos.map(todo => <TodoItem todo={todo} />);
});

2. Dereference Values Late

// Good: Only re-renders when name changes
const UserName = observer(({ user }) => {
  return <div>{user.name}</div>;
});

// Bad: Re-renders when any user property changes
const UserName = observer(({ userName }) => {
  return <div>{userName}</div>;
});

Summary

MobX makes state management simple and transparent:

  • Observable state automatically tracked
  • Actions to modify state
  • Computed values cached and derived
  • Reactions for side effects
  • Less boilerplate than Redux

Key Takeaways:

  1. Use makeAutoObservable for simplicity
  2. Use runInAction for async updates
  3. Use observer on React components
  4. Computed values are cached automatically
  5. Less code, more productivity

Next Steps:

  • Compare with Zustand
  • Learn Redux Toolkit
  • Try React Context

Resources: