Đặt vấn đề: Hạn chế của Context API trong các ứng dụng React quy mô lớn

Trong quá trình phát triển các ứng dụng ReactJS, việc quản lý trạng thái (state management) luôn là một trong những thách thức lớn nhất. Khi ứng dụng phát triển từ quy mô nhỏ lên mức trung bình và lớn, việc truyền tải state qua nhiều cấp component (prop drilling) trở nên vô cùng phức tạp và khó bảo trì. Để giải quyết vấn đề này, nhiều lập trình viên thường tìm đến Context API như một giải pháp cứu cánh có sẵn của React.

Tuy nhiên, Context API không phải là một công cụ quản lý state vạn năng. Vấn đề lớn nhất của Context API nằm ở hiệu năng render. Theo cơ chế hoạt động mặc định của React, khi giá trị của một Context Provider thay đổi, tất cả các component tiêu thụ (consume) Context đó thông qua hook useContext đều sẽ bị ép buộc re-render, bất kể component đó chỉ sử dụng một phần nhỏ (slice) của state và phần đó hoàn toàn không thay đổi.

Hãy tưởng tượng một hệ thống quản lý thông tin người dùng chứa cả thông tin cá nhân (tên, email) và cài đặt giao diện (theme, ngôn ngữ). Nếu một component chỉ cần hiển thị tên người dùng, nó vẫn sẽ bị re-render khi người dùng thay đổi cài đặt ngôn ngữ hệ thống. Trong các ứng dụng lớn với hàng trăm component hoạt động đồng thời, việc re-render dư thừa này sẽ dẫn đến hiện tượng giật lag, giảm trải nghiệm người dùng một cách nghiêm trọng.

Để giải quyết triệt để vấn đề này, chúng ta có thể tự thiết kế một hệ thống quản lý state tùy chỉnh (Custom State Store) nằm ngoài cây component của React (External Store) dựa trên mô hình Publish-Subscribe (Pub-Sub), sau đó đồng bộ hóa nó với React thông qua hook chuyên dụng useSyncExternalStore được giới thiệu từ phiên bản React 18.

Cơ chế Pub-Sub và Sự ra đời của useSyncExternalStore

Mô hình Publish-Subscribe (Pub-Sub) là gì?

Pub-Sub là một mẫu thiết kế kiến trúc phần mềm (architectural design pattern) cho phép các thành phần trong hệ thống giao tiếp với nhau một cách gián tiếp (loosely coupled). Trong mô hình này, bên gửi thông tin (Publisher) không gửi dữ liệu trực tiếp đến từng bên nhận cụ thể (Subscriber). Thay vào đó, Publisher sẽ phát đi một sự kiện (event) hoặc cập nhật một trạng thái, và tất cả các Subscriber đã đăng ký lắng nghe sự kiện đó sẽ tự động nhận được thông báo và cập nhật tương ứng.

Áp dụng vào quản lý state trong React, Store đóng vai trò là Publisher giữ nguồn dữ liệu duy nhất (Single Source of Truth). Các React component muốn sử dụng dữ liệu sẽ đăng ký (subscribe) lắng nghe sự thay đổi của Store. Khi dữ liệu trong Store thay đổi, Store sẽ thông báo cho tất cả các component đã đăng ký để chúng cập nhật giao diện.

Tại sao cần useSyncExternalStore?

Trước phiên bản React 18, việc đồng bộ hóa một store bên ngoài với React thường được thực hiện thủ công bằng cách sử dụng kết hợp useState hoặc useReducer bên trong một hook tùy chỉnh, kết hợp với hiệu ứng phụ trong useEffect để đăng ký lắng nghe sự thay đổi. Tuy nhiên, cách tiếp cận này gặp phải một vấn đề nghiêm trọng gọi là "tearing" (hiện tượng xé hình giao diện) khi sử dụng các tính năng render đồng thời (Concurrent Rendering) của React 18.

Tearing xảy ra khi React tạm dừng quá trình render một component để xử lý tác vụ có độ ưu tiên cao hơn, trong khoảng thời gian đó, store bên ngoài bị thay đổi bởi một tác vụ khác (ví dụ: một sự kiện mạng hoặc timer). Khi React tiếp tục render các component còn lại, các component này sẽ đọc giá trị mới từ store, dẫn đến việc các phần khác nhau của cùng một giao diện hiển thị dữ liệu không đồng nhất tại cùng một thời điểm.

Để giải quyết triệt để vấn đề này, đội ngũ phát triển React đã cung cấp hook useSyncExternalStore. Hook này đảm bảo việc đọc dữ liệu từ một nguồn bên ngoài (external store) luôn đồng bộ và nhất quán ngay cả trong chế độ Concurrent Rendering bằng cách ép buộc React render lại một cách đồng bộ khi phát hiện store bị thay đổi trong quá trình render.

Xây dựng Custom State Store từ con số 0 với TypeScript

Chúng ta sẽ bắt tay vào xây dựng một giải pháp quản lý state hoàn chỉnh, có khả năng phân tách dữ liệu (selector) để chỉ re-render component khi dữ liệu thực sự cần thiết thay đổi. Toàn bộ mã nguồn sẽ được viết bằng TypeScript để đảm bảo tính an toàn về kiểu dữ liệu (type safety).

Bước 1: Định nghĩa các kiểu dữ liệu cơ bản

Đầu tiên, chúng ta cần định nghĩa các kiểu dữ liệu cho Listener (hàm callback được gọi khi store thay đổi) và cấu trúc của Store.

export type Listener = () => void;

export interface Store<T> {
  getState: () => T;
  setState: (nextState: T | ((prevState: T) => T)) => void;
  subscribe: (listener: Listener) => () => void;
}

Bước 2: Triển khai hàm khởi tạo Store (createStore)

Hàm createStore sẽ chịu trách nhiệm lưu trữ trạng thái hiện tại, quản lý danh sách các listener đăng ký, và cung cấp các phương thức để tương tác với trạng thái đó.

export const createStore = <T>(initialState: T): Store<T> => {
  let state = initialState;
  const listeners = new Set<Listener>();

  const getState = () => state;

  const setState = (nextState: T | ((prevState: T) => T)) => {
    const resolvedState = typeof nextState === "function"
      ? (nextState as (prevState: T) => T)(state)
      : nextState;

    if (!Object.is(state, resolvedState)) {
      state = resolvedState;
      listeners.forEach((listener) => listener());
    }
  };

  const subscribe = (listener: Listener) => {
    listeners.add(listener);
    // Trả về hàm unsubscribe để hủy đăng ký khi component unmount
    return () => {
      listeners.delete(listener);
    };
  };

  return {
    getState,
    setState,
    subscribe,
  };
};

Trong đoạn mã trên, chúng ta sử dụng phương thức Object.is để so sánh trạng thái cũ và trạng thái mới. Điều này giúp ngăn chặn việc kích hoạt các listener khi trạng thái mới hoàn toàn trùng khớp với trạng thái cũ (ngăn ngừa re-render dư thừa).

Tích hợp Store với React bằng useSyncExternalStore

Sau khi đã có một Store hoạt động độc lập ngoài React, chúng ta cần tạo ra một cầu nối để các React component có thể sử dụng dữ liệu từ Store này một cách tối ưu nhất. Chúng ta sẽ xây dựng một custom hook có tên là useStore.

Hook này sẽ nhận vào một Store và một hàm selector. Hàm selector có nhiệm vụ trích xuất phần dữ liệu cụ thể mà component cần sử dụng từ toàn bộ trạng thái của Store.

import { useSyncExternalStore, useCallback } from "react";

export const useStore = <T, S>(
  store: Store<T>,
  selector: (state: T) => S
): S => {
  const subscribe = store.subscribe;
  
  // Sử dụng useCallback để giữ tham chiếu hàm getSnapshot ổn định
  const getSnapshot = useCallback(() => {
    return selector(store.getState());
  }, [store, selector]);

  return useSyncExternalStore(subscribe, getSnapshot);
};

Bằng cách sử dụng useSyncExternalStore kết hợp với selector, React sẽ chỉ kích hoạt quá trình re-render cho component hiện tại nếu và chỉ nếu giá trị trả về từ hàm selector thay đổi. Nếu các phần khác của Store thay đổi nhưng phần dữ liệu được chọn bởi selector giữ nguyên, component sẽ hoàn toàn không bị ảnh hưởng.

Ví dụ thực tiễn: Hệ thống Quản lý Giỏ hàng (Cart System)

Để thấy rõ sức mạnh của giải pháp này, chúng ta hãy cùng nhau triển khai một ví dụ thực tế: Hệ thống quản lý giỏ hàng của một trang thương mại điện tử.

Định nghĩa cấu trúc dữ liệu và khởi tạo Cart Store

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

interface CartState {
  items: CartItem[];
  couponCode: string;
  shippingFee: number;
}

const initialCartState: CartState = {
  items: [],
  couponCode: "",
  shippingFee: 30000,
};

export const cartStore = createStore<CartState>(initialCartState);

// Các hàm helper để cập nhật state (Actions)
export const addToCart = (product: Omit<CartItem, "quantity">) => {
  cartStore.setState((state) => {
    const existingItem = state.items.find((item) => item.id === product.id);
    if (existingItem) {
      return {
        ...state,
        items: state.items.map((item) =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        ),
      };
    }
    return {
      ...state,
      items: [...state.items, { ...product, quantity: 1 }],
    };
  });
};

export const updateCoupon = (code: string) => {
  cartStore.setState((state) => ({
    ...state,
    couponCode: code,
  }));
};

Xây dựng các Component tiêu thụ dữ liệu từ Store

Bây giờ chúng ta sẽ tạo ra ba component khác nhau để kiểm chứng khả năng tối ưu hóa re-render của giải pháp này.

Component 1: CartBadge (Chỉ hiển thị tổng số lượng sản phẩm)

Component này chỉ quan tâm đến tổng số lượng mặt hàng có trong giỏ hàng để hiển thị trên thanh điều hướng.

import React, { useRef } from "react";

export const CartBadge: React.FC = () => {
  const renderCount = useRef(0);
  renderCount.current++;

  // Selector chỉ trích xuất tổng số lượng sản phẩm
  const totalItems = useStore(
    cartStore,
    useCallback((state) => state.items.reduce((acc, item) => acc + item.quantity, 0), [])
  );

  return (
    <div style={{ padding: "10px", border: "1px solid #ccc" }}>
      <h3>Cart Badge Component</h3>
      <p>Số lượng sản phẩm trong giỏ: <strong>{totalItems}</strong></p>
      <p>Số lần re-render: <strong>{renderCount.current}</strong></p>
    </div>
  );
};

Component 2: CouponManager (Chỉ quản lý mã giảm giá)

Component này chỉ hiển thị và cho phép thay đổi mã giảm giá, hoàn toàn không quan tâm đến danh sách sản phẩm.

import React, { useRef } from "react";

export const CouponManager: React.FC = () => {
  const renderCount = useRef(0);
  renderCount.current++;

  // Selector chỉ trích xuất couponCode
  const couponCode = useStore(
    cartStore,
    useCallback((state) => state.couponCode, [])
  );

  return (
    <div style={{ padding: "10px", border: "1px solid #ccc", marginTop: "10px" }}>
      <h3>Coupon Manager Component</h3>
      <p>Mã giảm giá hiện tại: <strong>{couponCode || "Chưa áp dụng"}</strong></p>
      <input
        type="text"
        value={couponCode}
        onChange={(e) => updateCoupon(e.target.value)}
        placeholder="Nhập mã giảm giá"
      />
      <p>Số lần re-render: <strong>{renderCount.current}</strong></p>
    </div>
  );
};

Component 3: ProductList (Thêm sản phẩm vào giỏ hàng)

Component này chứa danh sách sản phẩm mẫu và nút bấm để thêm vào giỏ hàng. Component này không cần lắng nghe bất kỳ thay đổi nào từ store, nó chỉ thực hiện hành động (action).

import React from "react";

const SAMPLE_PRODUCTS = [
  { id: "p1", name: "Điện thoại iPhone 15", price: 25000000 },
  { id: "p2", name: "Tai nghe AirPods Pro", price: 5000000 },
];

export const ProductList: React.FC = () => {
  return (
    <div style={{ padding: "10px", border: "1px solid #ccc", marginTop: "10px" }}>
      <h3>Danh sách sản phẩm</h3>
      {SAMPLE_PRODUCTS.map((product) => (
        <div key={product.id} style={{ marginBottom: "10px" }}>
          <span>{product.name} - {product.price.toLocaleString()} VND </span>
          <button onClick={() => addToCart(product)}>Thêm vào giỏ</button>
        </div>
      ))}
    </div>
  );
};

Phân tích kết quả vận hành hệ thống

Khi tích hợp cả ba component trên vào một trang chính, bạn sẽ quan sát thấy một hành vi tối ưu hóa hiệu năng cực kỳ ấn tượng:

  • Khi bạn nhấn nút "Thêm vào giỏ" trong ProductList, số lượng sản phẩm thay đổi. Lúc này, CartBadge nhận diện được giá trị trả về từ selector của nó đã thay đổi (từ 0 lên 1) nên nó sẽ tiến hành re-render để cập nhật giao diện. Tuy nhiên, CouponManager hoàn toàn không bị re-render vì giá trị couponCode của nó vẫn giữ nguyên là chuỗi rỗng.
  • Ngược lại, khi bạn nhập ký tự vào ô input của CouponManager, trạng thái couponCode thay đổi. Lúc này, chỉ có duy nhất CouponManager bị re-render để hiển thị ký tự mới nhập. CartBadge hoàn toàn đứng yên và không bị re-render lại, mặc dù cả hai component đều đang chia sẻ chung một Store duy nhất là cartStore.

Đây chính là sự khác biệt vượt trội về mặt hiệu năng so với việc sử dụng Context API truyền thống.

So sánh chi tiết: Context API vs Custom Pub-Sub Store vs Redux/Zustand

Để có cái nhìn tổng quan và đưa ra quyết định chính xác cho dự án của mình, chúng ta hãy cùng đặt ba giải pháp này lên bàn cân so sánh:

1. Context API

  • Ưu điểm: Là giải pháp tích hợp sẵn của React, không cần cài đặt thêm thư viện bên thứ ba, cấu hình đơn giản, dễ học và dễ sử dụng.
  • Nhược điểm: Gây ra vấn đề re-render trên diện rộng khi state thay đổi. Không có cơ chế selector mặc định. Khó tối ưu hóa hiệu năng cho các ứng dụng có tần suất cập nhật state cao.
  • Phù hợp cho: Các dữ liệu ít thay đổi như cấu hình Theme (Sáng/Tối), thông tin bản địa hóa (Locale/Ngôn ngữ), hoặc thông tin người dùng đã đăng nhập (User Session).

2. Custom Pub-Sub Store (Giải pháp chúng ta vừa xây dựng)

  • Ưu điểm: Kiểm soát hiệu năng re-render cực kỳ chính xác nhờ cơ chế selector và useSyncExternalStore. Mã nguồn siêu nhẹ (chỉ khoảng vài chục dòng code), không làm tăng dung lượng bundle size của ứng dụng. Hoàn toàn làm chủ được luồng dữ liệu mà không phụ thuộc vào thư viện ngoài.
  • Nhược điểm: Phải tự viết mã nguồn thiết lập ban đầu, thiếu các công cụ hỗ trợ debug mạnh mẽ như Redux DevTools.
  • Phù hợp cho: Các module tính năng độc lập, các ứng dụng quy mô vừa và lớn muốn tối ưu hóa hiệu năng tối đa mà không muốn gánh thêm dung lượng từ các thư viện quản lý state cồng kềnh.

3. Các thư viện chuyên dụng (Zustand, Redux Toolkit)

  • Ưu điểm: Đầy đủ tính năng, hỗ trợ middleware xử lý bất đồng bộ, tích hợp sẵn các công cụ debug chuyên nghiệp (DevTools), cộng đồng hỗ trợ lớn. Thực chất, các thư viện hiện đại như Zustand cũng sử dụng cơ chế tương tự như Custom Store của chúng ta dưới mui xe (under the hood).
  • Nhược điểm: Làm tăng dung lượng dự án, mất thời gian học cú pháp và cấu hình ban đầu (đặc biệt là Redux).
  • Phù hợp cho: Các dự án quy mô doanh nghiệp (Enterprise) với hàng trăm lập trình viên cùng tham gia phát triển, đòi hỏi một quy chuẩn quản lý state thống nhất và chặt chẽ.

Những lưu ý quan trọng khi sử dụng useSyncExternalStore

Khi tự thiết kế và sử dụng Custom Store kết hợp với useSyncExternalStore, có một số quy tắc sống còn mà bạn bắt buộc phải tuân thủ để tránh gặp phải các lỗi logic khó kiểm soát:

  1. Giữ tham chiếu Selector ổn định: Hàm selector truyền vào hook useStore phải có tham chiếu ổn định. Nếu bạn định nghĩa một hàm selector ẩn danh trực tiếp trong lúc gọi hook (ví dụ: useStore(store, state => state.data)), tham chiếu của hàm này sẽ bị thay đổi sau mỗi lần component cha re-render. Điều này khiến useSyncExternalStore hiểu lầm rằng selector đã thay đổi và sẽ liên tục gọi lại hàm lấy snapshot, dễ dẫn đến vòng lặp re-render vô tận (infinite render loop). Hãy luôn bọc selector trong useCallback hoặc định nghĩa nó bên ngoài component.
  2. Tính bất biến của State (Immutability): Khi cập nhật trạng thái trong Store bằng phương thức setState, bạn phải luôn trả về một đối tượng state hoàn toàn mới (sử dụng spread operator ... hoặc các thư viện như Immer) thay vì thay đổi trực tiếp thuộc tính của đối tượng cũ (mutation). Nếu bạn mutate state trực tiếp, phép so sánh Object.is sẽ trả về true, Store sẽ nghĩ rằng dữ liệu không thay đổi và không thông báo cho các component cập nhật giao diện.

Kết luận

Việc tự tay xây dựng một Custom State Store dựa trên cơ chế Pub-Sub và tích hợp nó thông qua useSyncExternalStore không chỉ giúp bạn giải quyết triệt để bài toán tối ưu hóa hiệu năng re-render trong ReactJS, mà còn giúp bạn hiểu sâu sắc hơn về bản chất cách thức hoạt động bên dưới của các thư viện quản lý trạng thái nổi tiếng hiện nay. Việc làm chủ những kỹ thuật chuyên sâu này là bước đệm quan trọng để bạn bứt phá giới hạn từ một lập trình viên thông thường trở thành một kỹ sư Front-End thực thụ.

Để làm chủ toàn diện các kỹ thuật tối ưu hiệu năng nâng cao, tư duy kiến trúc ứng dụng thực chiến và cách kết hợp hoàn hảo giữa ReactJS với hệ thống kiểu dữ liệu chặt chẽ của TypeScript, bạn có thể tham khảo lộ trình đào tạo bài bản từ chuyên gia tại Unicode Academy. Tham khảo khóa học "Lập trình Front-End với ReactJS + TypeScript" tại đây.