MobX: Quản Lý State trong Ứng Dụng Phức Tạp (React Roadmap)

Chào các bạn trên hành trình khám phá React! Chúng ta đã cùng nhau đi qua nhiều cột mốc quan trọng trong Lộ trình React Roadmap này. Chúng ta đã tìm hiểu React là gì, cách xây dựng giao diện bằng JSXComponent, làm quen với Props và State cơ bản, cũng như các Hooks mạnh mẽ như useState và useEffect.

Khi ứng dụng của bạn lớn dần, việc quản lý state trở nên phức tạp hơn nhiều. Chúng ta đã thấy các giải pháp tích hợp sẵn trong React như useReducer cho state phức tạp trong component hoặc useContext để truyền state xuống sâu mà không cần props drilling. Chúng ta cũng đã lướt qua các thư viện quản lý state bên ngoài như Redux (với Redux Toolkit), Recoil và Zustand trong bài viết Chọn Công Cụ Quản Lý State Nào?.

Hôm nay, chúng ta sẽ đi sâu vào một lựa chọn phổ biến và mạnh mẽ khác: MobX. MobX có cách tiếp cận khác biệt so với Redux hay Context/useReducer, tập trung vào khái niệm “phản ứng” (reactivity), mang lại trải nghiệm phát triển linh hoạt và thường yêu cầu ít code boilerplate hơn.

State Management là Thách Thức Lớn trong Ứng Dụng Lớn

Hãy hình dung một ứng dụng web phức tạp: một trang thương mại điện tử, một trình soạn thảo văn bản trực tuyến, hoặc một bảng điều khiển quản lý dự án. State của ứng dụng này không chỉ đơn giản là bật/tắt một nút hay nhập liệu vào một ô input. Nó bao gồm:

  • Danh sách sản phẩm, giỏ hàng, thông tin người dùng (thương mại điện tử).
  • Nội dung tài liệu, lịch sử undo/redo, trạng thái lựa chọn (trình soạn thảo).
  • Danh sách công việc, trạng thái dự án, thông báo real-time (quản lý dự án).

Khi state này cần được chia sẻ giữa nhiều component khác nhau, đặc biệt là những component ở các cấp độ sâu trong cây component tree, các vấn đề bắt đầu nảy sinh:

  • Props Drilling: Bạn phải truyền state và các hàm cập nhật state qua nhiều lớp component trung gian, dù những component đó không thực sự cần dữ liệu đó. Điều này làm cho code trở nên khó đọc, khó bảo trì.
  • Logic Phân Mảnh: Logic liên quan đến việc thay đổi state có thể bị phân tán khắp nơi trong các component hoặc các hàm handler.
  • Khó Theo Dõi Thay Đổi: Khi ứng dụng phản ứng với sự thay đổi của state không như mong đợi, việc debug để tìm ra nguyên nhân trở nên khó khăn.

Đây chính là lúc các thư viện quản lý state bên ngoài phát huy tác dụng. Chúng cung cấp một “ngôi nhà” tập trung cho state của ứng dụng và các quy tắc để cập nhật state đó, giúp giải quyết các vấn đề trên.

MobX: Cách Tiếp Cận Phản Ứng (Reactivity)

Nếu bạn đã quen với Redux, bạn sẽ thấy MobX có một triết lý khác biệt đáng kể. Redux dựa trên khái niệm state bất biến (immutable state) và luồng dữ liệu một chiều (one-way data flow), nơi bạn cập nhật state bằng cách tạo ra một object state *mới* dựa trên state cũ và action. MobX thì lại dựa trên khái niệm state có thể thay đổi được (mutable state) và mô hình “phản ứng”.

Hãy nghĩ về một bảng tính Excel hoặc Google Sheets. Khi bạn thay đổi giá trị của một ô (ví dụ: A1), bất kỳ ô nào khác có công thức phụ thuộc vào A1 (ví dụ: B1 = A1 + 10) sẽ tự động cập nhật giá trị của nó. Đó chính là reactivity!

MobX hoạt động theo nguyên tắc tương tự. Bạn định nghĩa những “phần tử” nào của state là “có thể quan sát được” (observable). Sau đó, bạn định nghĩa những “phản ứng” (reactions) hoặc những giá trị “tính toán được” (computed values) phụ thuộc vào state có thể quan sát đó. Khi state có thể quan sát thay đổi, MobX sẽ tự động chạy lại các phản ứng hoặc tính toán lại các giá trị phụ thuộc, và quan trọng nhất, chỉ cập nhật những phần giao diện React thực sự bị ảnh hưởng.

Mô hình này giúp giảm thiểu code boilerplate vì bạn không cần viết code để “kết nối” các thay đổi state với việc cập nhật UI một cách thủ công. MobX làm điều đó tự động cho bạn.

Các Khái Niệm Cốt Lõi của MobX

Để làm việc với MobX, bạn cần nắm vững bốn khái niệm chính:

  1. State (Observable): Đây là dữ liệu cốt lõi của ứng dụng mà bạn muốn MobX theo dõi sự thay đổi. MobX có thể làm cho các kiểu dữ liệu JavaScript thông thường như object, array, primitive values trở thành “observable”.
  2. Actions: Bất kỳ đoạn code nào làm thay đổi state có thể quan sát được đều nên được đánh dấu là một action. Actions giúp cấu trúc code tốt hơn, theo dõi thay đổi state bằng MobX Devtools, và trong một số trường hợp, cải thiện hiệu suất bằng cách nhóm nhiều thay đổi nhỏ lại thành một lần cập nhật duy nhất.
  3. Computed Values: Đây là những giá trị được dẫn xuất (derive) từ state có thể quan sát được. MobX tự động ghi nhớ (memoize) các computed values và chỉ tính toán lại chúng khi state phụ thuộc thay đổi. Điều này rất hiệu quả cho việc lọc danh sách, tính toán tổng, hoặc kiểm tra điều kiện phức tạp dựa trên state.
  4. Reactions: Đây là các hàm tự động chạy lại khi state có thể quan sát mà chúng phụ thuộc vào thay đổi. Reactions thường được sử dụng cho các tác vụ phụ (side effects) như cập nhật giao diện người dùng (React components là dạng phổ biến nhất của reaction khi dùng với mobx-react), ghi log, đồng bộ hóa với server, v.v. Có nhiều loại reaction tích hợp sẵn như autorun, reaction, when.

Trong thực tế, bạn thường sử dụng các decorator (hoặc annotations trong các phiên bản mới hơn) hoặc các hàm tiện ích như makeObservable, makeAutoObservable để đánh dấu các thuộc tính và phương thức trong các class hoặc object JavaScript thuần túy của bạn.

import { makeAutoObservable } from "mobx";

class TodoStore {
  todos = [];
  filter = "";

  constructor() {
    // Biến tất cả thuộc tính thành observable,
    // các getter thành computed,
    // và các phương thức thành action.
    makeAutoObservable(this);
  }

  addTodo(task) {
    this.todos.push({
      id: Date.now(),
      task: task,
      completed: false,
    });
  }

  removeTodo(id) {
    this.todos = this.todos.filter(todo => todo.id !== id);
  }

  setFilter(filter) {
    this.filter = filter;
  }

  get completedTodosCount() {
    // Computed value: Tự động tính toán lại khi todos thay đổi
    console.log("Calculating completedTodosCount"); // Demo: Chỉ chạy lại khi cần
    return this.todos.filter(todo => todo.completed).length;
  }

  get filteredTodos() {
    // Computed value: Tự động tính toán lại khi todos hoặc filter thay đổi
    const matchFilter = new RegExp(this.filter, "i");
    return this.todos.filter(todo => !this.filter || matchFilter.test(todo.task));
  }
}

// Tạo một instance của store
const store = new TodoStore();

Trong ví dụ trên:

  • todosfilterstate (observable).
  • addTodo, removeTodo, setFilteractions làm thay đổi state.
  • completedTodosCountfilteredTodoscomputed values được dẫn xuất từ state.

Kết Hợp MobX với React Components

Để các component React của bạn “phản ứng” với sự thay đổi của MobX state, bạn sử dụng thư viện mobx-react hoặc mobx-react-lite (phiên bản nhẹ hơn, chỉ hỗ trợ functional components và hooks). Công cụ chính ở đây là hàm observer.

Khi bạn bọc một functional component bằng observer, MobX sẽ theo dõi *những observable nào* được component đó truy cập trong quá trình render. Bất cứ khi nào một trong những observable đó thay đổi, MobX sẽ tự động kích hoạt render lại cho *chỉ* component đó.

import React from "react";
import { observer } from "mobx-react-lite"; // Hoặc "mobx-react"
// Giả sử store được export từ file khác
// import { store } from "./stores/TodoStore";

const TodoList = observer(() => {
  // Truy cập observable state
  const todos = store.filteredTodos; // Truy cập computed value (phụ thuộc vào todos và filter)
  const completedCount = store.completedTodosCount; // Truy cập computed value

  return (
    <div>
      <h2>Todo List</h2>
      <p>Completed: {completedCount} / {store.todos.length}</p> {/* Truy cập state trực tiếp */}
      <input
        type="text"
        placeholder="Filter tasks..."
        value={store.filter} // Truy cập state
        onChange={(e) => store.setFilter(e.target.value)} // Kích hoạt action
      />
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.task}
            <button onClick={() => store.removeTodo(todo.id)}>Remove</button> {/* Kích hoạt action */}
          </li>
        ))}
      </ul>
      <button onClick={() => store.addTodo("New Task " + (store.todos.length + 1))}>
        Add Todo
      </button> {/* Kích hoạt action */}
    </div>
  );
});

export default TodoList;

Lưu ý cách component TodoList truy cập trực tiếp vào store.filteredTodos, store.completedTodosCount, store.filter, store.todos.length và gọi các action store.setFilter(...), store.removeTodo(...), store.addTodo(...). Nhờ được bọc bởi observer, component này sẽ tự động render lại một cách hiệu quả *chỉ khi* những giá trị MobX mà nó sử dụng thay đổi.

Cung cấp Store cho Component Tree

Giống như Context API, bạn có thể cung cấp instance store của mình xuống component tree bằng cách sử dụng Context của React, thường kết hợp với hook useContext trong các component con.

import React, { createContext, useContext } from "react";
import { store } from "./stores/TodoStore"; // Import instance store đã tạo

// Tạo Context
const StoreContext = createContext(store);

// Custom hook để dễ dàng sử dụng store
export const useStore = () => useContext(StoreContext);

// Component Provider bọc ứng dụng
const StoreProvider = ({ children }) => (
  <StoreContext.Provider value={store}>
    {children}
  </StoreContext.Provider>
);

export default StoreProvider;

Và trong file App.js hoặc entry point:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import StoreProvider from "./stores/StoreContext";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <StoreProvider> {/* Bọc toàn bộ ứng dụng bằng StoreProvider */}
      <App />
    </StoreProvider>
  </React.StrictMode>
);

Bây giờ, bất kỳ component con nào trong ứng dụng cũng có thể truy cập store bằng cách gọi const store = useStore(); và sau đó sử dụng nó trong component được bọc bởi observer.

So Sánh MobX với Các Công Cụ Khác

Chúng ta đã có một bài viết tổng quan về các lựa chọn quản lý state. Dưới đây là bảng so sánh chi tiết hơn một chút về MobX so với Context API và Redux, những lựa chọn phổ biến nhất:

Tiêu chí Context API + useReducer Redux (với Redux Toolkit) MobX
Triết lý Quản lý state phân tán/cục bộ, truyền state qua context. Sử dụng Reducer cho logic phức tạp. State tập trung, bất biến (immutable state), luồng dữ liệu một chiều, reducer thuần túy. State tập trung/phân tán (linh hoạt), có thể thay đổi (mutable state), mô hình phản ứng (reactivity).
Boilerplate Ít nếu state đơn giản. Tăng lên với Provider/Context và logic Reducer phức tạp. Khá nhiều (slice, reducer, action, store config), nhưng Redux Toolkit đã giảm đáng kể. Thường ít nhất. Định nghĩa observable/action/computed là đủ.
Đường cong học tập Dễ nếu quen hooks/reducers. Khó khăn khi scale lên nhiều context hoặc logic phức tạp. Trung bình đến khó. Nhiều khái niệm cần nắm vững (store, reducer, action, middleware). Redux Toolkit giúp dễ hơn. Trung bình. Khái niệm reactivity có thể mới lạ với người mới. Cần hiểu rõ observable/action/computed/reaction.
Hiệu suất cập nhật UI Khi state trong Context thay đổi, tất cả component con sử dụng Context đó đều render lại theo mặc định (trừ khi dùng useMemo/useCallback hoặc React.memo). Tối ưu. Selector giúp component chỉ render lại khi *phần state* mà nó sử dụng thay đổi. Rất tối ưu (fine-grained reactivity). Component bọc bởi observer chỉ render lại khi *observable* cụ thể mà nó truy cập thay đổi.
Thay đổi State Thông qua hàm dispatch trả về từ useReducer. Thông qua hàm dispatch với các action object. Trực tiếp thay đổi các thuộc tính observable (nên được bọc trong action).
Kiểm soát State Mỗi Reducer quản lý một phần state riêng. Store trung tâm duy nhất với các Reducer kết hợp. Có thể có nhiều store hoặc một store lớn duy nhất. Linh hoạt hơn.
Phù hợp với Ứng dụng nhỏ đến trung bình, state cần chia sẻ đơn giản, hoặc state cục bộ phức tạp. Ứng dụng lớn, phức tạp, cần quản lý state chặt chẽ, dễ đoán, dễ debug (nhờ devtools mạnh mẽ). Ứng dụng lớn, cần hiệu suất cao, state graph phức tạp, hoặc dev thích tiếp cận OOP/decorator, ít boilerplate.

Ưu và Nhược Điểm của MobX

Giống như mọi công cụ khác, MobX cũng có điểm mạnh và điểm yếu:

Ưu điểm:

  • Ít Boilerplate: So với Redux truyền thống, MobX yêu cầu ít code “khung” hơn đáng kể để định nghĩa state, actions và kết nối với React.
  • Dễ Học và Sử Dụng (cho một số người): Đối với developer quen thuộc với OOP hoặc mô hình phản ứng, MobX có thể cảm thấy tự nhiên và trực quan hơn. Bạn chỉ cần thay đổi state và MobX lo phần còn lại.
  • Hiệu Suất Tốt: Mô hình reactivity cho phép MobX cập nhật UI một cách rất hiệu quả, chỉ render lại những component thực sự cần thiết.
  • Linh Hoạt: Bạn có thể tổ chức store theo nhiều cách khác nhau (một store lớn, nhiều store nhỏ).
  • Tích hợp Tốt với JS Thuần Túy: Bạn có thể làm cho các class/object JS thông thường trở nên reactive thay vì phải làm việc với các object bất biến theo một cấu trúc chặt chẽ.

Nhược điểm:

  • Mô Hình Phản Ứng “Magic”: Đối với người mới, cơ chế tự động của MobX có thể cảm thấy như “phép màu”, khó hiểu tại sao component lại render lại hoặc không render lại trong một số trường hợp đặc biệt. Điều này đòi hỏi phải hiểu rõ cách MobX theo dõi các observable.
  • State Mutable: Việc thay đổi state trực tiếp có thể làm cho việc theo dõi nguồn gốc của một bug trở nên khó khăn hơn so với mô hình bất biến của Redux, trừ khi bạn sử dụng actions đúng cách và kết hợp với MobX Devtools.
  • Yêu Cầu Cấu Hình Ban Đầu: Việc cài đặt và cấu hình decorator hoặc Babel/TypeScript có thể là một bước cản ban đầu, mặc dù makeObservable/makeAutoObservable đã giảm bớt điều này.
  • Cần Sử Dụng Actions: Để đảm bảo tính nhất quán và khả năng debug, việc luôn thay đổi state trong action là một khuyến cáo mạnh mẽ, nhưng không phải là bắt buộc về mặt kỹ thuật (trừ khi bật chế độ nghiêm ngặt), điều này có thể dẫn đến lỗi nếu không tuân thủ.

Khi Nào Nên Cân Nhắc Sử Dụng MobX?

MobX là một lựa chọn tuyệt vời nếu:

  • Bạn ưa thích mô hình state có thể thay đổi và cách tiếp cận dựa trên reactivity.
  • Bạn muốn giảm thiểu boilerplate code tối đa.
  • Ứng dụng của bạn có state graph phức tạp, các giá trị computed được sử dụng nhiều.
  • Bạn cần hiệu suất cập nhật UI rất tối ưu và fine-grained reactivity.
  • Đội ngũ của bạn cảm thấy thoải mái hơn với cách tiếp cận giống OOP (dùng class để định nghĩa store).

Tuy nhiên, nếu bạn đang xây dựng một ứng dụng rất lớn, cần một luồng dữ liệu cực kỳ dễ đoán và debug được từ xa một cách dễ dàng (ví dụ: với Redux Devtools có khả năng time-travel debugging), hoặc đội ngũ của bạn đã quen thuộc và thoải mái với triết lý bất biến của Redux, thì Redux vẫn là một lựa chọn rất vững chắc. Context API phù hợp cho các nhu cầu đơn giản hơn hoặc state cục bộ.

Kết Luận

MobX mang đến một cách tiếp cận mạnh mẽ và hiệu quả để quản lý state trong các ứng dụng React phức tạp, dựa trên mô hình reactivity tự động. Bằng cách định nghĩa state có thể quan sát (observables), các hành động (actions), giá trị tính toán được (computed values), và các phản ứng (reactions), bạn có thể xây dựng các ứng dụng có state dễ quản lý, cập nhật mượt mà và hiệu suất cao với ít code boilerplate hơn.

Hiểu rõ các khái niệm cốt lõi và cách MobX tích hợp với React qua hàm observer là chìa khóa để làm chủ thư viện này. Hãy thử áp dụng MobX vào một dự án nhỏ hoặc một phần của dự án hiện tại để cảm nhận sự khác biệt trong cách quản lý state.

Chúng ta đã khám phá MobX như một lựa chọn mạnh mẽ trong “vũ trụ” quản lý state của React, bên cạnh Context API, Redux, Recoil, và Zustand. Việc lựa chọn công cụ phù hợp phụ thuộc vào quy mô dự án, sở thích của đội ngũ và yêu cầu cụ thể của ứng dụng.

Hy vọng bài viết này đã cung cấp cho bạn cái nhìn sâu sắc về MobX và cách sử dụng nó để giải quyết thách thức quản lý state trong các ứng dụng phức tạp. Hãy tiếp tục theo dõi Lộ trình React Roadmap để khám phá thêm nhiều kiến thức và kỹ năng thú vị khác nhé! Hẹn gặp lại trong các bài viết tiếp theo!

Chỉ mục