Suspense hiển thị một fallback, chẳng hạn dòng chữ đang tải, trong lúc các component bên trong nó chưa sẵn sàng. React.lazy là thứ hay phải chờ nhất: nó chỉ tải code của một component khi component đó render lần đầu, giúp lượng tải ban đầu nhỏ gọn.
Nhấn Show chart: dòng Loading chart... hiện ra trong một giây, sau đó đến biểu đồ. Ẩn rồi hiện lại, biểu đồ xuất hiện ngay lập tức mà không có dòng log mới, vì lazy giữ lại module đã tải.
Code splitting với lazy
Trình soạn thảo chứa mọi thứ trong một tệp, nên ví dụ phải tự dựng một module chậm: một promise resolve thành đối tượng có export default sau một giây. Trong ứng dụng thật, component nằm trong tệp riêng và bạn truyền vào một dynamic import:
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart.jsx'));
export default function Dashboard() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
);
}
import('./Chart.jsx') trả về một promise cho module. Các bundler như Vite và webpack nhận ra dynamic import, rồi đưa Chart.jsx cùng mọi thứ chỉ nó dùng vào một tệp riêng, được tải về ở lần đầu <Chart /> render. Những quy tắc cần nắm:
- Module phải có default export.
lazyđọc thuộc tínhdefaultcủa giá trị mà promise resolve ra. Với named export, hãy ánh xạ lại:lazy(() => import('./charts.js').then((m) => ({ default: m.LineChart }))). - Gọi
lazyở cấp cao nhất của module. Nếu đặt bên trong component, mỗi lần render sẽ tạo ra một kiểu component mới, nên React sẽ unmount component cũ, mất state của nó và tải lại từ đầu. - Tách code ở những chỗ người dùng vốn phải chờ. Route, modal, panel ít khi mở và các widget nặng (trình soạn thảo, biểu đồ, bản đồ) là những ứng viên tốt. Tách mọi component nhỏ chỉ thêm request và trạng thái loading mà chẳng được lợi gì.
Preload trước khi người dùng nhấn
Một lazy component bắt đầu tải khi nó render lần đầu, nên sau khi nhấn, người dùng luôn phải chờ ít nhất một lượt tải. Nếu đoán trước được cú nhấn sắp tới, hãy bắt đầu tải sớm hơn. Lưu hàm import vào một biến và gọi nó khi hover hoặc focus; trình duyệt giữ lại module, nên khi lazy gọi lại cùng lệnh import đó, nó resolve ngay mà không phải tải lần hai.
const loadChart = () => import('./Chart.jsx');
const Chart = lazy(loadChart);
<button onMouseEnter={loadChart} onFocus={loadChart} onClick={() => setShow(true)}>
Show chart
</button>
Suspense quyết định hiển thị gì như thế nào
Khi một component bên trong <Suspense> chưa sẵn sàng, nó sẽ suspend: React dừng render phần đó và hiển thị fallback của Suspense gần nhất phía trên. Toàn bộ nội dung trong boundary đó bị thay bằng fallback, chứ không chỉ riêng component đang chờ. Khi thứ cần chờ đã sẵn sàng, React render lại nội dung và thay fallback đi.
Vì thế, vị trí đặt boundary là một quyết định thiết kế. Hãy đặt các phần độc lập vào boundary riêng để mỗi phần hiện ra ngay khi sẵn sàng:
Lúc đầu cả trang hiển thị Loading page..., vì Header thuộc về boundary bên ngoài. Khi header đã sẵn sàng, bài viết hiện ra với Loading comments... ở bên dưới, và phần bình luận đến sau cùng. Đổi 1500 thành 3000, bản xem trước tải lại và chỉ phần bình luận phải chờ lâu hơn. Xóa <Suspense> bên trong (giữ lại <Comments />), trang sẽ chờ phần bình luận rồi mới hiển thị bất cứ thứ gì.
Suspense với use() trong React 19
Trong React 19, component có thể đọc một promise bằng use(promise). Nếu promise vẫn đang pending, component sẽ suspend và Suspense gần nhất hiển thị fallback; khi promise resolve, use trả về giá trị. Trang về hook use trình bày chi tiết. Hàm fetchUser giả bên dưới đóng vai một request thật.
Lần lượt nhấn qua các user, rồi quay lại User 1: nó hiện ra ngay và Console không ghi thêm lượt fetch nào, vì promise đã nằm sẵn trong cache.
Vì sao promise phải được cache
Map cache ở đây không phải để tối ưu, mà là bắt buộc. Một component đã suspend không giữ lại gì từ lần thử đó: khi promise resolve, React render nó lại từ đầu. Nếu Profile gọi thẳng fetchUser(id), mỗi lần thử sẽ tạo một promise mới, gửi một request mới và lại suspend trên promise đó. Hồ sơ không bao giờ hiện ra, còn Console thì đầy các dòng fetching user. Vì vậy promise phải đến từ một nơi tồn tại lâu hơn lần render:
- một cache theo khóa là request, như map ở trên (các thư viện dữ liệu như TanStack Query và loader của framework làm việc này giúp bạn);
- một component cha tạo promise đúng một lần, trong trình xử lý sự kiện hoặc một Server Component, rồi truyền xuống qua prop.
Lưu ý rằng mỗi khi chọn user mới, hồ sơ vẫn bị thay bằng fallback. Nếu muốn giữ user cũ trên màn hình cho đến khi user tiếp theo sẵn sàng, hãy bọc lần cập nhật trong một transition: startTransition(() => setId(n)). Với transition, React không ẩn nội dung đang hiển thị (xem useTransition).
Lỗi cần đến error boundary
Suspense lo phần chờ đợi, không lo phần thất bại. Nếu một lazy import thất bại (người dùng mất mạng, một lần deploy mới đã xóa chunk cũ) hoặc một promise truyền vào use bị reject, React ném lỗi đến error boundary gần nhất. Nếu không có error boundary, toàn bộ cây bên dưới root sẽ bị unmount. Error boundary hiện vẫn là class component (xem error boundary):
<ErrorBoundary fallback={<p>Could not load the chart.</p>}>
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
</ErrorBoundary>
Những gì Suspense không phát hiện được
Suspense chỉ phản ứng với các component có suspend: component dùng lazy, use(promise) và các nguồn dữ liệu được xây dựng cho Suspense (loader của framework, thư viện hỗ trợ Suspense). Một lệnh fetch bên trong useEffect cập nhật state khi hoàn tất thì không suspend, nên Suspense boundary bao quanh nó không bao giờ hiển thị fallback. Với cách làm đó, bạn tự quản lý state loading của mình, như trang về fetch dữ liệu đã trình bày.
Câu hỏi thường gặp
React Suspense là gì?
<Suspense fallback={...}> là một component hiển thị fallback trong lúc bất kỳ component nào bên trong nó đang chờ một thứ gì đó, chẳng hạn code được tải lazy hoặc dữ liệu đọc bằng use. Khi mọi thứ bên trong đã sẵn sàng, React thay fallback bằng nội dung thật.
React.lazy dùng để làm gì?
lazy(() => import('./Chart.jsx')) tạo ra một component mà code của nó chỉ được tải về ở lần render đầu tiên. Bundler đặt tệp đó vào một chunk riêng, nhờ vậy trang ban đầu tải ít JavaScript hơn.
Suspense có dùng được khi fetch dữ liệu không?
Có, nếu nguồn dữ liệu hỗ trợ. Trong React 19, component có thể đọc một promise bằng use(promise) và suspend cho đến khi promise resolve. Các framework như Next.js cũng tích hợp Suspense vào cơ chế tải dữ liệu của chúng. Một lệnh fetch bên trong useEffect thì không kích hoạt Suspense.
Xử lý lỗi với Suspense như thế nào?
Suspense chỉ lo phần chờ đợi. Nếu một lazy import hoặc một promise thất bại, lỗi sẽ được chuyển đến error boundary gần nhất, vì vậy bạn cần bọc Suspense boundary (hoặc component cha của nó) trong một error boundary.
Nên gọi lazy ở đâu?
Ở cấp cao nhất của module, bên ngoài mọi component. Gọi lazy bên trong component sẽ tạo ra một kiểu component mới sau mỗi lần render, khiến state bị đặt lại và component bị tải lại.