React Suspense: Hiểu Rõ ‘Sắp Ra Mắt’ và ‘Đã Sẵn Sàng’ (React Roadmap)

Xin chào mừng các bạn trở lại với series “React Roadmap”! Trên hành trình khám phá hệ sinh thái React ngày càng phát triển này, chúng ta đã đi qua rất nhiều khái niệm cốt lõi, từ React là gì, sự chuyển đổi từ Class sang Functional Components, cách JSX hoạt động, cho đến việc quản lý dữ liệu với Props và State, sử dụng Hooks mạnh mẽ như useState và useEffect, và tạo Custom Hooks để tái sử dụng logic. Chúng ta cũng đã đào sâu vào các phương pháp quản lý state phức tạp hơn với useReducer hay quản lý global state với useContext, thậm chí tìm hiểu các lựa chọn quản lý state hiện đại khác.

Ngày hôm nay, chúng ta sẽ chạm đến một chủ đề đang định hình tương lai của React: React Suspense. Nếu bạn đã từng vật lộn với việc hiển thị trạng thái loading khi dữ liệu chưa sẵn sàng hoặc khi một phần code đang được tải về, thì Suspense chính là giải pháp mà React đề xuất. Tuy nhiên, Suspense là một khái niệm với hai khía cạnh rõ ràng: một phần đã sẵn sàng để sử dụng trong production, và một phần vẫn đang trong quá trình phát triển và thử nghiệm. Bài viết này sẽ giúp bạn phân biệt rõ hai khía cạnh này, hiểu cách sử dụng chúng và tầm quan trọng của Suspense trên lộ trình trở thành một lập trình viên React giỏi.

Suspense Là Gì? Vấn Đề Nó Giải Quyết

Trong các ứng dụng web truyền thống, việc xử lý các tác vụ bất đồng bộ (asynchronous tasks) như tải dữ liệu từ API, tải hình ảnh, hoặc tải các đoạn mã JavaScript (code splitting) thường dẫn đến một thử thách chung: làm thế nào để hiển thị giao diện người dùng (UI) khi tài nguyên cần thiết chưa sẵn sàng?

Cách tiếp cận phổ biến trước đây là quản lý trạng thái loading và error một cách thủ công trong từng component sử dụng dữ liệu hoặc mã bất đồng bộ. Ví dụ, khi gọi API trong useEffect, chúng ta thường dùng `useState` để theo dõi trạng thái `isLoading`, `isError`, và dữ liệu `data`:

import React, { useState, useEffect } from 'react';
import axios from 'axios'; // Giả sử dùng axios, hoặc dùng fetch API như bài trước về <a href="https://tuyendung.evotek.vn/react-roadmap-cach-goi-api-voi-axios-va-fetch/">Cách Gọi API Với Axios và Fetch</a>

function MyComponent({ userId }) {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);
      setError(null);
      try {
        const response = await axios.get(`/api/users/${userId}`);
        setData(response.data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchData();
  }, [userId]); // Dependency array

  if (isLoading) {
    return <div>Đang tải dữ liệu...</div>;
  }

  if (error) {
    return <div>Đã xảy ra lỗi: {error.message}</div>;
  }

  if (!data) {
    return <div>Không tìm thấy dữ liệu.</div>;
  }

  return (
    <div>
      <h2>Thông tin người dùng:</h2>
      <p>Tên: {data.name}</p>
      {/* ... hiển thị dữ liệu khác */}
    </div>
  );
}

Cách tiếp cận này hoạt động, nhưng nó có nhược điểm:

  • Code rườm rà: Mỗi component cần xử lý tác vụ bất đồng bộ đều phải lặp lại logic quản lý trạng thái `isLoading` và `isError`.
  • Waterfalls (Thác nước): Nếu một component cha render ra nhiều component con, và mỗi component con lại tự tải dữ liệu của nó, trình duyệt sẽ phải đợi component cha render xong, rồi các component con mới bắt đầu tải dữ liệu. Điều này tạo ra một chuỗi các yêu cầu tải tuần tự, làm chậm quá trình hiển thị tổng thể và gây ra hiệu ứng “nhảy nhót” trên giao diện khi các phần tử lần lượt xuất hiện.
  • Race Conditions (Điều kiện tranh chấp): Khi một component gọi API trong `useEffect` và props thay đổi nhanh chóng (ví dụ: `userId` thay đổi), có thể xảy ra trường hợp phản hồi từ yêu cầu cũ đến sau phản hồi từ yêu cầu mới, dẫn đến hiển thị sai dữ liệu. Việc xử lý race conditions trong `useEffect` đòi hỏi thêm logic cleanup phức tạp.
  • Trải nghiệm người dùng không mượt mà: Việc hiển thị nhiều spinner hoặc nội dung placeholder không đồng bộ có thể khiến giao diện trông lộn xộn.

Suspense được giới thiệu để giải quyết những vấn đề này bằng cách cung cấp một cách tiếp cận tích hợp, đơn giản và hiệu quả hơn để xử lý trạng thái chờ tài nguyên sẵn sàng.

React Suspense: Cơ Chế “Đình Chỉ” Render

Về cốt lõi, Suspense hoạt động dựa trên ý tưởng rằng một component có thể “đình chỉ” (suspend) quá trình render của nó khi nó cần chờ một tài nguyên bất đồng bộ nào đó (như dữ liệu, mã code, hình ảnh, v.v.) sẵn sàng. Khi một component đình chỉ, nó sẽ “ném” ra một Promise (hoặc một đối tượng đặc biệt mà React hiểu là Promise). React sẽ bắt lấy Promise đó, và thay vì hiển thị component đang chờ, nó sẽ hiển thị một giao diện dự phòng (fallback UI) được định nghĩa bởi component `` gần nhất ở cây component.

Khi Promise được giải quyết (dữ liệu tải xong, mã code tải xong, v.v.), React sẽ thử render lại component bị đình chỉ. Lần này, nếu tài nguyên đã sẵn sàng, component sẽ render thành công. Nếu có lỗi xảy ra trong quá trình chờ, React sẽ tìm đến một component Error Boundary gần nhất để xử lý lỗi (việc sử dụng Error Boundary là rất quan trọng khi dùng Suspense).

Đây là cơ chế chung, nhưng cách bạn kích hoạt việc “đình chỉ” này phụ thuộc vào loại tác vụ bất đồng bộ và trạng thái hiện tại của React Suspense.

Khía Cạnh “Đã Sẵn Sàng”: Suspense cho Code Splitting

Đây là tính năng đầu tiên và hiện tại là tính năng ổn định nhất của Suspense, đã sẵn sàng để sử dụng trong các ứng dụng production. Suspense kết hợp với `React.lazy` giúp chia nhỏ bundle code JavaScript của ứng dụng (code splitting) thành các phần nhỏ hơn và chỉ tải chúng khi cần thiết. Điều này đặc biệt hữu ích với các routes hoặc các phần UI không hiển thị ngay khi ứng dụng khởi động.

Trước đây, code splitting thường phức tạp hơn, đòi hỏi cấu hình ở mức bundler (như Webpack). Với `React.lazy`, nó trở nên đơn giản hơn rất nhiều:

// Trước đây: import MyHeavyComponent from './MyHeavyComponent';
// Sau này: dùng React.lazy để tải động

import React, { lazy, Suspense } from 'react';

// Định nghĩa component được tải động
const MyLazyComponent = lazy(() => import('./MyHeavyComponent'));

function App() {
  return (
    <div>
      <h1>Ứng dụng của tôi</h1>
      {/* Bọc component được lazy load bằng Suspense */}
      <Suspense fallback={<div>Đang tải component...</div>}>
        <MyLazyComponent />
      </Suspense>
    </div>
  );
}

Trong ví dụ này:

  • `lazy(() => import(‘./MyHeavyComponent’))` tạo ra một “lazy component” bọc quanh component `MyHeavyComponent`. Mã của `MyHeavyComponent` sẽ chỉ được tải xuống khi `MyLazyComponent` lần đầu tiên được render.
  • `` là component mà chúng ta bọc quanh `MyLazyComponent`. Prop `fallback` nhận một React element (có thể là một spinner, placeholder, hoặc bất kỳ UI nào) sẽ được hiển thị trong khi `MyLazyComponent` và mã của nó đang được tải.

Đây là cách dùng Suspense cho code splitting, và nó đã là một phần ổn định của React từ lâu. Nó thường được sử dụng kết hợp với các thư viện routing như React Router để tải code theo route.

Khía Cạnh “Sắp Ra Mắt/Thử Nghiệm”: Suspense cho Data Fetching

Đây là nơi tiềm năng lớn nhất và cũng là phần vẫn đang trong giai đoạn thử nghiệm tích cực. Ý tưởng là cho phép component “đình chỉ” render trong khi chờ dữ liệu được tải về. Điều này thay đổi hoàn toàn cách chúng ta tiếp cận việc fetch dữ liệu trong React.

Thay vì fetch dữ liệu *trong* `useEffect` (sau khi render lần đầu), hoặc sử dụng các pattern như “fetch-on-render” (gọi fetch trong render, dẫn đến render lặp lại), với Suspense cho data fetching, component chỉ cần “khai báo” rằng nó cần dữ liệu X. Nếu dữ liệu chưa có, component sẽ “đình chỉ”, và React cùng `` sẽ lo phần hiển thị fallback.

// Đây là cách tiếp cận TƯƠNG LAI hoặc sử dụng thư viện TƯƠNG THÍCH
// KHÔNG phải là code React cơ bản bạn có thể chạy ngay bây giờ mà không cần setup đặc biệt.

// Ví dụ (mang tính minh họa cao, dựa trên các thư viện hỗ trợ Suspense)
import React, { Suspense } from 'react';
// Giả sử có một hàm hoặc hook fetch dữ liệu hỗ trợ Suspense
import { useData } from './data-fetching-library'; // Ví dụ: React Query v3+, SWR, Apollo Client v3+

function UserProfile({ userId }) {
  // useData này sẽ "ném" Promise nếu dữ liệu chưa có
  const userData = useData(`/api/users/${userId}`); // Khai báo dữ liệu cần

  // Nếu useData "ném" Promise, component này sẽ dừng render,
  // React sẽ tìm Suspense boundary gần nhất.
  // Nếu dữ liệu đã sẵn sàng, nó sẽ tiếp tục render.

  return (
    <div>
      <h2>Thông tin người dùng:</h2>
      <p>Tên: {userData.name}</p>
      {/* ... hiển thị dữ liệu khác */}
    </div>
  );
}

function AppWithData() {
  const [currentUserId, setCurrentUserId] = React.useState(1);

  return (
    <div>
      <h1>Ứng dụng Data Fetching</h1>
      <button onClick={() => setCurrentUserId(currentUserId === 1 ? 2 : 1)}>
        Chuyển đổi người dùng
      </button>
      <Suspense fallback={<div>Đang tải profile người dùng...</div>}>
        <UserProfile userId={currentUserId} />
      </Suspense>
    </div>
  );
}

Trong ví dụ trên, `UserProfile` component *không* có logic `isLoading`, `isError` hay `useEffect` để fetch dữ liệu. Nó chỉ đơn giản gọi `useData`. Hàm `useData` (do một thư viện cung cấp) sẽ là nơi xử lý việc fetch thực tế. Nếu dữ liệu cho `userId` đó chưa có trong cache hoặc đang được tải, `useData` sẽ kích hoạt cơ chế Suspense, và `` boundary sẽ hiển thị fallback.

Điều này có một số lợi ích lớn:

  • Code sạch hơn: Logic loading/error được tách ra khỏi component hiển thị. Component chỉ tập trung vào việc hiển thị dữ liệu khi nó sẵn sàng.
  • Tránh Race Conditions: Các thư viện tích hợp Suspense sẽ quản lý việc cache dữ liệu và xử lý các yêu cầu thay đổi (ví dụ: khi `userId` thay đổi nhanh chóng) một cách an toàn, đảm bảo component nhận được dữ liệu đúng cho phiên bản render hiện tại.
  • Trải nghiệm người dùng tốt hơn: React có thể phối hợp việc tải mã và tải dữ liệu. Với các tính năng Concurrent Rendering (cơ chế nền tảng của Suspense cho data fetching), React có thể tiếp tục phản hồi các tương tác của người dùng trong khi đang chờ dữ liệu, hoặc thậm chí chuẩn bị hiển thị UI mới ở chế độ nền trước khi chuyển đổi mượt mà. `startTransition` là một API liên quan cho phép đánh dấu các cập nhật state gây ra việc chờ đợi là “transitions”, giúp React giữ cho UI chính phản hồi.

Quan trọng: Tính năng Suspense cho Data Fetching *không* có nghĩa là React core sẽ cung cấp một hook `useData(url)` hoạt động ngay lập tức. React core chỉ cung cấp cơ chế “đình chỉ” và component ``. Việc tích hợp cơ chế này vào các thư viện fetch dữ liệu (như React Query, SWR, Apollo Client, Relay) là cần thiết. Các thư viện này sẽ cung cấp các hook hoặc API data fetching hỗ trợ Suspense.

Cơ Chế Nền Tảng: Concurrent React Features

Suspense cho data fetching không thể hoạt động hiệu quả mà không có cơ chế nền tảng là Concurrent Features (các tính năng đồng thời) của React. Đây là một sự thay đổi lớn trong mô hình render của React, cho phép React làm việc trên nhiều “phiên bản” của UI cùng lúc, ngắt quãng công việc render để xử lý các cập nhật ưu tiên cao hơn (như nhập liệu), và chỉ “commit” kết quả cuối cùng lên DOM khi nó sẵn sàng.

Khi một component “đình chỉ”, Concurrent React cho phép React tạm dừng việc render nhánh cây đó, chuyển sang làm việc khác (ví dụ: xử lý một tương tác khác của người dùng), và quay lại tiếp tục render khi dữ liệu đã sẵn sàng. Điều này giúp ứng dụng của bạn phản hồi nhanh hơn và mượt mà hơn, đặc biệt trong các trường hợp nhiều phần của UI cùng tải dữ liệu hoặc code.

Mặc dù các lập trình viên chủ yếu tương tác với Concurrent Features thông qua các API như `` và `startTransition`, việc hiểu rằng đây là một sự thay đổi kiến trúc nền tảng giúp giải thích tại sao Suspense cho data fetching lại mạnh mẽ và khác biệt so với cách tiếp cận truyền thống.

Sự Khác Biệt Giữa “Đã Sẵn Sàng” và “Sắp Ra Mắt”

Để tóm tắt rõ ràng, đây là sự phân biệt giữa hai khía cạnh của Suspense:

  • Suspense cho Code Splitting (Đã Sẵn Sàng):
    • Sử dụng với `React.lazy`.
    • Giúp tải mã JavaScript của các component một cách động khi chúng được render lần đầu.
    • Ổn định và sẵn sàng cho production.
    • Thường dùng kết hợp với các thư viện routing.
  • Suspense cho Data Fetching (Sắp Ra Mắt/Thử Nghiệm):
    • Cho phép component “đình chỉ” render khi chờ dữ liệu bất đồng bộ.
    • Thay đổi mô hình fetch dữ liệu từ “fetch effect, then render” sang “render, then suspend if needed”.
    • Dựa trên Concurrent Features của React.
    • Đòi hỏi các thư viện data fetching (như React Query v3+, SWR, Apollo Client v3+, Relay) phải được tích hợp và hỗ trợ Suspense.
    • Vẫn đang trong quá trình phát triển và thử nghiệm, mặc dù các thư viện lớn đã bắt đầu hỗ trợ. Việc sử dụng nó cho data fetching trong production cần thận trọng và hiểu rõ cách thư viện data fetching bạn chọn hỗ trợ nó như thế nào.

Đây là một bảng tóm tắt:

Tính năng Suspense Trạng thái Cách sử dụng Lợi ích chính Ghi chú
Code Splitting Đã sẵn sàng (Stable) `React.lazy` + `<Suspense>` Tối ưu kích thước bundle, tải mã theo yêu cầu, cải thiện thời gian tải ban đầu. Kết hợp tốt với routing.
Data Fetching Đang thử nghiệm (Experimental/Stable qua thư viện) Sử dụng các thư viện Data Fetching hỗ trợ Suspense Đơn giản hóa quản lý trạng thái loading/error, tránh race conditions, trải nghiệm người dùng mượt mà hơn (với Concurrent Features). Không có hook fetch data built-in từ React core. Phụ thuộc vào việc tích hợp của các thư viện. Cần Error Boundary để xử lý lỗi.

Các Bước Tiếp Theo và Lời Khuyên

Với tư cách là một lập trình viên đang trên “React Roadmap” của mình (React Roadmap – Lộ trình học React 2025), bạn nên làm gì với React Suspense?

  1. Nắm vững Code Splitting với `React.lazy` và ``: Đây là tính năng đã ổn định và nên được áp dụng trong hầu hết các ứng dụng React hiện đại để cải thiện hiệu suất tải.
  2. Hiểu rõ tầm nhìn của Suspense cho Data Fetching: Mặc dù chưa hoàn toàn là một API “built-in” dễ dùng cho mọi trường hợp fetch dữ liệu, việc hiểu rõ mục tiêu và cách nó hoạt động (dựa trên Concurrent Features) sẽ giúp bạn chuẩn bị cho tương lai.
  3. Khám phá các thư viện Data Fetching hiện đại: Các thư viện như React Query hoặc SWR (đã thảo luận trong bài trước), hay các GraphQL client như Apollo Client hoặc Relay, đang dẫn đầu trong việc tích hợp Suspense. Học cách sử dụng chúng không chỉ giúp bạn fetch dữ liệu hiệu quả hơn ngay bây giờ mà còn làm quen với mô hình fetch dữ liệu của tương lai.
  4. Sử dụng ``: Suspense và Error Boundary đi đôi với nhau. Suspense xử lý trạng thái *chờ*, còn Error Boundary xử lý trạng thái *lỗi*. Hãy đảm bảo bạn biết cách sử dụng Error Boundary để bắt lỗi xảy ra trong các thành phần con, đặc biệt là những component sử dụng Suspense cho data fetching.
  5. Thử nghiệm với `startTransition`: Nếu bạn muốn cung cấp trải nghiệm người dùng mượt mà hơn trong các cập nhật state có thể gây ra trạng thái chờ (ví dụ: chuyển tab, lọc danh sách lớn), hãy tìm hiểu về `startTransition`.

React Suspense không chỉ là một component mới, nó là một phần của sự chuyển dịch lớn hơn trong cách React xử lý các tác vụ bất đồng bộ và render UI. Bằng cách đưa việc chờ đợi tài nguyên vào cốt lõi của mô hình render, React cho phép chúng ta xây dựng các ứng dụng phức tạp hơn với code sạch hơn và trải nghiệm người dùng tốt hơn.

Kết Luận

React Suspense là một tính năng quan trọng trên lộ trình phát triển React, giải quyết bài toán hiển thị UI khi tài nguyên chưa sẵn sàng một cách thanh lịch và hiệu quả. Khía cạnh code splitting với `React.lazy` đã sẵn sàng và nên được áp dụng rộng rãi. Khía cạnh data fetching vẫn đang được phát triển và thử nghiệm, chủ yếu thông qua sự tích hợp của các thư viện bên ngoài, nhưng nó đại diện cho tương lai của việc fetch dữ liệu trong React.

Hiểu và làm quen với Suspense, đặc biệt là cách nó tương tác với Concurrent Features và các thư viện data fetching hiện đại, sẽ giúp bạn viết code React hiệu quả hơn, cung cấp trải nghiệm người dùng vượt trội và sẵn sàng đón nhận những thay đổi trong tương lai của framework.

Hãy tiếp tục hành trình “React Roadmap” của bạn và đừng ngần ngại thử nghiệm Suspense trong các dự án của mình (bắt đầu với code splitting!). Chúc bạn học tốt!

Chỉ mục