Khám phá React Suspense cho việc tìm nạp dữ liệu ngoài code splitting. Hiểu rõ Fetch-As-You-Render, xử lý lỗi, và các mẫu cho ứng dụng toàn cầu trong tương lai.
Tải Tài Nguyên với React Suspense: Làm Chủ Các Mẫu Tìm Nạp Dữ Liệu Hiện Đại
Trong thế giới phát triển web năng động, trải nghiệm người dùng (UX) là yếu tố tối thượng. Các ứng dụng được kỳ vọng phải nhanh, đáp ứng tốt và thú vị, bất kể điều kiện mạng hay khả năng của thiết bị. Đối với các nhà phát triển React, điều này thường dẫn đến việc quản lý trạng thái phức tạp, các chỉ báo tải rắc rối và một cuộc chiến không ngừng chống lại các thác nước tìm nạp dữ liệu (data fetching waterfalls). Đây là lúc React Suspense xuất hiện, một tính năng mạnh mẽ, dù thường bị hiểu lầm, được thiết kế để thay đổi cơ bản cách chúng ta xử lý các hoạt động bất đồng bộ, đặc biệt là tìm nạp dữ liệu.
Ban đầu được giới thiệu cho việc tách mã (code splitting) với React.lazy()
, tiềm năng thực sự của Suspense nằm ở khả năng điều phối việc tải *bất kỳ* tài nguyên bất đồng bộ nào, bao gồm cả dữ liệu từ API. Hướng dẫn toàn diện này sẽ đi sâu vào React Suspense để tải tài nguyên, khám phá các khái niệm cốt lõi, các mẫu tìm nạp dữ liệu cơ bản và những cân nhắc thực tế để xây dựng các ứng dụng toàn cầu hiệu suất cao và linh hoạt.
Sự Tiến Hóa của Việc Tìm Nạp Dữ Liệu trong React: Từ Mệnh Lệnh đến Khai Báo
Trong nhiều năm, việc tìm nạp dữ liệu trong các component React chủ yếu dựa vào một mẫu chung: sử dụng hook useEffect
để khởi tạo một lệnh gọi API, quản lý trạng thái tải và lỗi bằng useState
, và render có điều kiện dựa trên các trạng thái này. Mặc dù hoạt động được, cách tiếp cận này thường dẫn đến một số thách thức:
- Sự Tràn Lan của Trạng Thái Tải: Hầu hết mọi component cần dữ liệu đều phải có các trạng thái
isLoading
,isError
, vàdata
riêng, dẫn đến mã lặp đi lặp lại. - Thác Nước và Tình Trạng Tranh Chấp (Race Conditions): Các component lồng nhau tìm nạp dữ liệu thường dẫn đến các yêu cầu tuần tự (thác nước), nơi một component cha sẽ tìm nạp dữ liệu, sau đó render, rồi một component con sẽ tìm nạp dữ liệu của nó, và cứ thế tiếp diễn. Điều này làm tăng thời gian tải tổng thể. Tình trạng tranh chấp cũng có thể xảy ra khi nhiều yêu cầu được khởi tạo và các phản hồi đến không theo thứ tự.
- Xử Lý Lỗi Phức Tạp: Việc phân phối thông báo lỗi và logic phục hồi qua nhiều component có thể rất cồng kềnh, đòi hỏi phải truyền prop qua nhiều cấp hoặc các giải pháp quản lý trạng thái toàn cục.
- Trải Nghiệm Người Dùng Khó Chịu: Nhiều spinner xuất hiện rồi biến mất, hoặc nội dung thay đổi đột ngột (layout shifts), có thể tạo ra trải nghiệm khó chịu cho người dùng.
- Truyền Prop Dữ Liệu và Trạng Thái: Việc truyền dữ liệu đã tìm nạp và các trạng thái tải/lỗi liên quan xuống qua nhiều cấp component đã trở thành một nguồn phức tạp phổ biến.
Hãy xem xét một kịch bản tìm nạp dữ liệu điển hình không có Suspense:
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setIsLoading(true);
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
setError(e);
} finally {
setIsLoading(false);
}
};
fetchUser();
}, [userId]);
if (isLoading) {
return <p>Đang tải hồ sơ người dùng...</p>;
}
if (error) {
return <p style={"color: red;"}>Lỗi: {error.message}</p>;
}
if (!user) {
return <p>Không có dữ liệu người dùng.</p>;
}
return (
<div>
<h2>Người dùng: {user.name}</h2>
<p>Email: {user.email}</p>
<!-- Thêm chi tiết người dùng -->
</div>
);
}
function App() {
return (
<div>
<h1>Chào mừng đến với Ứng dụng</h1>
<UserProfile userId={"123"} />
</div>
);
}
Mẫu này rất phổ biến, nhưng nó buộc component phải tự quản lý trạng thái bất đồng bộ của mình, thường dẫn đến mối quan hệ耦合 chặt chẽ giữa UI và logic tìm nạp dữ liệu. Suspense cung cấp một giải pháp thay thế mang tính khai báo và tinh gọn hơn.
Hiểu React Suspense Ngoài Việc Tách Mã (Code Splitting)
Hầu hết các nhà phát triển lần đầu tiên gặp Suspense qua React.lazy()
để tách mã, nơi nó cho phép bạn trì hoãn việc tải mã của một component cho đến khi cần thiết. Ví dụ:
import React, { Suspense, lazy } from 'react';
const LazyComponent = lazy(() => import('./MyHeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Đang tải component...</div>}>
<LazyComponent />
</Suspense>
);
}
Trong kịch bản này, nếu MyHeavyComponent
chưa được tải, ranh giới <Suspense>
sẽ bắt promise được ném ra bởi lazy()
và hiển thị fallback
cho đến khi mã của component sẵn sàng. Điểm mấu chốt ở đây là Suspense hoạt động bằng cách bắt các promise được ném ra trong quá trình render.
Cơ chế này không chỉ dành riêng cho việc tải mã. Bất kỳ hàm nào được gọi trong quá trình render mà ném ra một promise (ví dụ: vì một tài nguyên chưa có sẵn) đều có thể bị bắt bởi một ranh giới Suspense ở cấp cao hơn trong cây component. Khi promise được giải quyết, React sẽ cố gắng render lại component, và nếu tài nguyên đã có sẵn, fallback sẽ bị ẩn đi và nội dung thực tế sẽ được hiển thị.
Các Khái Niệm Cốt Lõi của Suspense cho Việc Tìm Nạp Dữ Liệu
Để tận dụng Suspense cho việc tìm nạp dữ liệu, chúng ta cần hiểu một vài nguyên tắc cốt lõi:
1. Ném ra một Promise (Throwing a Promise)
Không giống như mã bất đồng bộ truyền thống sử dụng async/await
để giải quyết các promise, Suspense dựa vào một hàm *ném ra* một promise nếu dữ liệu chưa sẵn sàng. Khi React cố gắng render một component gọi một hàm như vậy, và dữ liệu vẫn đang chờ xử lý, promise sẽ được ném ra. React sau đó sẽ 'tạm dừng' việc render của component đó và các con của nó, tìm kiếm ranh giới <Suspense>
gần nhất.
2. Ranh giới Suspense (The Suspense Boundary)
Component <Suspense>
hoạt động như một ranh giới lỗi cho các promise. Nó nhận một prop fallback
, là UI sẽ được render trong khi bất kỳ component con nào của nó (hoặc hậu duệ của chúng) đang tạm dừng (tức là đang ném ra một promise). Một khi tất cả các promise được ném ra trong cây con của nó được giải quyết, fallback sẽ được thay thế bằng nội dung thực tế.
Một ranh giới Suspense duy nhất có thể quản lý nhiều hoạt động bất đồng bộ. Ví dụ, nếu bạn có hai component trong cùng một ranh giới <Suspense>
, và mỗi component cần tìm nạp dữ liệu, fallback sẽ hiển thị cho đến khi *cả hai* quá trình tìm nạp dữ liệu hoàn tất. Điều này tránh hiển thị UI một phần và cung cấp một trải nghiệm tải phối hợp hơn.
3. Trình Quản Lý Cache/Tài Nguyên (Trách nhiệm của Userland)
Quan trọng là, bản thân Suspense không xử lý việc tìm nạp dữ liệu hay caching. Nó chỉ là một cơ chế điều phối. Để làm cho Suspense hoạt động với việc tìm nạp dữ liệu, bạn cần một lớp:
- Khởi tạo việc tìm nạp dữ liệu.
- Cache kết quả (dữ liệu đã giải quyết hoặc promise đang chờ xử lý).
- Cung cấp một phương thức
read()
đồng bộ, hoặc trả về dữ liệu đã cache ngay lập tức (nếu có) hoặc ném ra promise đang chờ xử lý (nếu không).
Trình quản lý tài nguyên này thường được triển khai bằng một cache đơn giản (ví dụ: một Map hoặc một object) để lưu trữ trạng thái của mỗi tài nguyên (đang chờ, đã giải quyết, hoặc lỗi). Mặc dù bạn có thể tự xây dựng nó cho mục đích minh họa, trong một ứng dụng thực tế, bạn sẽ sử dụng một thư viện tìm nạp dữ liệu mạnh mẽ tích hợp với Suspense.
4. Chế độ Đồng thời (Concurrent Mode - Cải tiến của React 18)
Mặc dù Suspense có thể được sử dụng trong các phiên bản React cũ hơn, sức mạnh đầy đủ của nó được giải phóng với Concurrent React (được bật mặc định trong React 18 với createRoot
). Chế độ Đồng thời cho phép React ngắt, tạm dừng và tiếp tục công việc render. Điều này có nghĩa là:
- Cập nhật UI không chặn: Khi Suspense hiển thị một fallback, React có thể tiếp tục render các phần khác của UI không bị tạm dừng, hoặc thậm chí chuẩn bị UI mới ở chế độ nền mà không chặn luồng chính.
- Transitions: Các API mới như
useTransition
cho phép bạn đánh dấu một số cập nhật là 'transitions,' mà React có thể ngắt và làm cho chúng ít khẩn cấp hơn, cung cấp các thay đổi UI mượt mà hơn trong quá trình tìm nạp dữ liệu.
Các Mẫu Tìm Nạp Dữ Liệu với Suspense
Hãy cùng khám phá sự tiến hóa của các mẫu tìm nạp dữ liệu với sự ra đời của Suspense.
Mẫu 1: Tìm nạp rồi Render (Fetch-Then-Render - Truyền thống với Suspense)
Đây là cách tiếp cận cổ điển nơi dữ liệu được tìm nạp, và chỉ sau đó component mới được render. Mặc dù không tận dụng cơ chế 'ném promise' trực tiếp cho dữ liệu, bạn có thể bọc một component *cuối cùng* sẽ render dữ liệu trong một ranh giới Suspense để cung cấp một fallback. Điều này thiên về việc sử dụng Suspense như một bộ điều phối UI tải chung cho các component cuối cùng sẽ sẵn sàng, ngay cả khi việc tìm nạp dữ liệu bên trong của chúng vẫn dựa trên useEffect
truyền thống.
import React, { Suspense, useState, useEffect } from 'react';
function UserDetails({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchUserData = async () => {
setIsLoading(true);
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
setUser(data);
setIsLoading(false);
};
fetchUserData();
}, [userId]);
if (isLoading) {
return <p>Đang tải chi tiết người dùng...</p>;
}
return (
<div>
<h3>Người dùng: {user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Ví dụ Fetch-Then-Render</h1>
<Suspense fallback={<div>Đang tải trang tổng thể...</div>}>
<UserDetails userId={"1"} />
</Suspense>
</div>
);
}
Ưu điểm: Dễ hiểu, tương thích ngược. Có thể được sử dụng như một cách nhanh chóng để thêm trạng thái tải toàn cục.
Nhược điểm: Không loại bỏ mã lặp bên trong UserDetails
. Vẫn dễ bị thác nước nếu các component tìm nạp dữ liệu tuần tự. Không thực sự tận dụng cơ chế 'ném-và-bắt' của Suspense cho chính dữ liệu.
Mẫu 2: Render rồi Tìm nạp (Render-Then-Fetch - Tìm nạp bên trong Render, không dành cho Production)
Mẫu này chủ yếu để minh họa những gì không nên làm trực tiếp với Suspense, vì nó có thể dẫn đến vòng lặp vô hạn hoặc các vấn đề về hiệu suất nếu không được xử lý cẩn thận. Nó liên quan đến việc cố gắng tìm nạp dữ liệu hoặc gọi một hàm tạm dừng trực tiếp trong pha render của một component, *mà không* có cơ chế caching phù hợp.
// KHÔNG SỬ DỤNG TRONG PRODUCTION MÀ KHÔNG CÓ LỚP CACHING PHÙ HỢP
// Đây hoàn toàn là để minh họa cách một 'throw' trực tiếp có thể hoạt động về mặt khái niệm.
let fetchedData = null;
let dataPromise = null;
function fetchDataSynchronously(url) {
if (fetchedData) {
return fetchedData;
}
if (!dataPromise) {
dataPromise = fetch(url)
.then(res => res.json())
.then(data => { fetchedData = data; dataPromise = null; return data; })
.catch(err => { dataPromise = null; throw err; });
}
throw dataPromise; // Đây là nơi Suspense hoạt động
}
function UserDetailsBadExample({ userId }) {
const user = fetchDataSynchronously(`/api/users/${userId}`);
return (
<div>
<h3>Người dùng: {user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
}
function App() {
return (
<div>
<h1>Render-Then-Fetch (Minh họa, KHÔNG khuyến nghị trực tiếp)</h1>
<Suspense fallback={<div>Đang tải người dùng...</div>}>
<UserDetailsBadExample userId={"2"} />
</Suspense>
</div>
);
}
Ưu điểm: Cho thấy cách một component có thể trực tiếp 'yêu cầu' dữ liệu và tạm dừng nếu chưa sẵn sàng.
Nhược điểm: Rất có vấn đề cho môi trường production. Hệ thống fetchedData
và dataPromise
toàn cục, thủ công này quá đơn giản, không xử lý nhiều yêu cầu, vô hiệu hóa cache, hoặc các trạng thái lỗi một cách mạnh mẽ. Đây là một minh họa sơ khai về khái niệm 'ném-một-promise', không phải là một mẫu để áp dụng.
Mẫu 3: Tìm nạp trong lúc Render (Fetch-As-You-Render - Mẫu Suspense Lý Tưởng)
Đây là sự thay đổi mô hình mà Suspense thực sự mang lại cho việc tìm nạp dữ liệu. Thay vì chờ một component render rồi mới tìm nạp dữ liệu, hoặc tìm nạp tất cả dữ liệu từ đầu, Fetch-As-You-Render có nghĩa là bạn bắt đầu tìm nạp dữ liệu *càng sớm càng tốt*, thường là *trước* hoặc *đồng thời với* quá trình render. Các component sau đó 'đọc' dữ liệu từ một cache, và nếu dữ liệu chưa sẵn sàng, chúng sẽ tạm dừng. Ý tưởng cốt lõi là tách biệt logic tìm nạp dữ liệu khỏi logic render của component.
Để triển khai Fetch-As-You-Render, bạn cần một cơ chế để:
- Khởi tạo một lần tìm nạp dữ liệu bên ngoài hàm render của component (ví dụ: khi một route được nhập, hoặc một nút được nhấp).
- Lưu trữ promise hoặc dữ liệu đã giải quyết trong một cache.
- Cung cấp một cách để các component 'đọc' từ cache này. Nếu dữ liệu chưa có sẵn, hàm đọc sẽ ném ra promise đang chờ xử lý.
Mẫu này giải quyết vấn đề thác nước. Nếu hai component khác nhau cần dữ liệu, các yêu cầu của chúng có thể được khởi tạo song song, và UI sẽ chỉ xuất hiện khi *cả hai* đều sẵn sàng, được điều phối bởi một ranh giới Suspense duy nhất.
Triển khai thủ công (để hiểu)
Để nắm bắt cơ chế cơ bản, hãy tạo một trình quản lý tài nguyên thủ công đơn giản. Trong một ứng dụng thực tế, bạn sẽ sử dụng một thư viện chuyên dụng.
import React, { Suspense } from 'react';
// --- Trình Quản Lý Cache/Tài Nguyên Đơn Giản --- //
const cache = new Map();
function createResource(promise) {
let status = 'pending';
let result;
let suspender = promise.then(
(r) => {
status = 'success';
result = r;
},
(e) => {
status = 'error';
result = e;
}
);
return {
read() {
if (status === 'pending') {
throw suspender;
} else if (status === 'error') {
throw result;
} else if (status === 'success') {
return result;
}
},
};
}
function fetchData(key, fetcher) {
if (!cache.has(key)) {
cache.set(key, createResource(fetcher()));
}
return cache.get(key);
}
// --- Các Hàm Tìm Nạp Dữ Liệu --- //
const fetchUserById = (id) => {
console.log(`Đang tìm nạp người dùng ${id}...`);
return new Promise(resolve => setTimeout(() => {
const users = {
'1': { id: '1', name: 'Alice Smith', email: 'alice@example.com' },
'2': { id: '2', name: 'Bob Johnson', email: 'bob@example.com' },
'3': { id: '3', name: 'Charlie Brown', email: 'charlie@example.com' }
};
resolve(users[id]);
}, 1500));
};
const fetchPostsByUserId = (userId) => {
console.log(`Đang tìm nạp bài viết cho người dùng ${userId}...`);
return new Promise(resolve => setTimeout(() => {
const posts = {
'1': [{ id: 'p1', title: 'Bài Viết Đầu Tiên Của Tôi' }, { id: 'p2', title: 'Những Cuộc Phiêu Lưu Du Lịch' }],
'2': [{ id: 'p3', title: 'Góc Nhìn Về Lập Trình' }],
'3': [{ id: 'p4', title: 'Xu Hướng Toàn Cầu' }, { id: 'p5', title: 'Ẩm Thực Địa Phương' }]
};
resolve(posts[userId] || []);
}, 2000));
};
// --- Các Component --- //
function UserProfile({ userId }) {
const userResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
const user = userResource.read(); // Sẽ tạm dừng nếu dữ liệu người dùng chưa sẵn sàng
return (
<div>
<h3>Người dùng: {user.name}</h3>
<p>Email: {user.email}</p>
</div>
);
}
function UserPosts({ userId }) {
const postsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
const posts = postsResource.read(); // Sẽ tạm dừng nếu dữ liệu bài viết chưa sẵn sàng
return (
<div>
<h4>Bài viết của {userId}:</h4>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
{posts.length === 0 && <li>Không tìm thấy bài viết nào.</li>}
</ul>
</div>
);
}
// --- Ứng dụng --- //
let initialUserResource = null;
let initialPostsResource = null;
function prefetchDataForUser(userId) {
initialUserResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
initialPostsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
}
// Tìm nạp trước một số dữ liệu trước khi component App được render
prefetchDataForUser('1');
function App() {
return (
<div>
<h1>Fetch-As-You-Render với Suspense</h1>
<p>Điều này minh họa cách tìm nạp dữ liệu có thể diễn ra song song, được điều phối bởi Suspense.</p>
<Suspense fallback={<div>Đang tải hồ sơ người dùng và bài viết...</div>}>
<UserProfile userId={"1"} />
<UserPosts userId={"1"} />
</Suspense>
<h2>Một Phần Khác</h2>
<Suspense fallback={<div>Đang tải người dùng khác...</div>}>
<UserProfile userId={"2"} />
</Suspense>
</div>
);
}
Trong ví dụ này:
- Các hàm
createResource
vàfetchData
thiết lập một cơ chế caching cơ bản. - Khi
UserProfile
hoặcUserPosts
gọiresource.read()
, chúng hoặc nhận được dữ liệu ngay lập tức hoặc promise được ném ra. - Ranh giới
<Suspense>
gần nhất sẽ bắt (các) promise và hiển thị fallback của nó. - Quan trọng là, chúng ta có thể gọi
prefetchDataForUser('1')
*trước khi* componentApp
được render, cho phép việc tìm nạp dữ liệu bắt đầu sớm hơn nữa.
Các thư viện cho Fetch-As-You-Render
Xây dựng và duy trì một trình quản lý tài nguyên mạnh mẽ theo cách thủ công là rất phức tạp. May mắn thay, một số thư viện tìm nạp dữ liệu trưởng thành đã áp dụng hoặc đang áp dụng Suspense, cung cấp các giải pháp đã được kiểm chứng:
- React Query (TanStack Query): Cung cấp một lớp tìm nạp và caching dữ liệu mạnh mẽ với hỗ trợ Suspense. Nó cung cấp các hook như
useQuery
có thể tạm dừng. Rất tuyệt vời cho các API REST. - SWR (Stale-While-Revalidate): Một thư viện tìm nạp dữ liệu phổ biến và nhẹ khác hỗ trợ đầy đủ Suspense. Lý tưởng cho các API REST, nó tập trung vào việc cung cấp dữ liệu nhanh chóng (dữ liệu cũ) và sau đó xác thực lại ở chế độ nền.
- Apollo Client: Một client GraphQL toàn diện có tích hợp Suspense mạnh mẽ cho các truy vấn và mutation GraphQL.
- Relay: Client GraphQL của chính Facebook, được thiết kế từ đầu cho Suspense và Concurrent React. Nó yêu cầu một schema GraphQL cụ thể và một bước biên dịch nhưng mang lại hiệu suất và tính nhất quán dữ liệu vô song.
- Urql: Một client GraphQL nhẹ và có khả năng tùy biến cao với hỗ trợ Suspense.
Các thư viện này trừu tượng hóa sự phức tạp của việc tạo và quản lý tài nguyên, xử lý caching, xác thực lại, cập nhật lạc quan và xử lý lỗi, giúp việc triển khai Fetch-As-You-Render dễ dàng hơn nhiều.
Mẫu 4: Tìm Nạp Trước (Prefetching) với các Thư viện Hỗ trợ Suspense
Tìm nạp trước là một tối ưu hóa mạnh mẽ nơi bạn chủ động tìm nạp dữ liệu mà người dùng có khả năng cần trong tương lai gần, trước cả khi họ yêu cầu một cách rõ ràng. Điều này có thể cải thiện đáng kể hiệu suất cảm nhận được.
Với các thư viện hỗ trợ Suspense, việc tìm nạp trước trở nên liền mạch. Bạn có thể kích hoạt tìm nạp dữ liệu dựa trên các tương tác của người dùng không làm thay đổi UI ngay lập tức, chẳng hạn như di chuột qua một liên kết hoặc nút.
import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// Giả sử đây là các lệnh gọi API của bạn
const fetchProductById = async (id) => {
console.log(`Đang tìm nạp sản phẩm ${id}...`);
return new Promise(resolve => setTimeout(() => {
const products = {
'A001': { id: 'A001', name: 'Global Widget X', price: 29.99, description: 'Một widget đa năng cho sử dụng quốc tế.' },
'B002': { id: 'B002', name: 'Universal Gadget Y', price: 149.99, description: 'Thiết bị tiên tiến, được yêu thích trên toàn thế giới.' },
};
resolve(products[id]);
}, 1000));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // Bật Suspense cho tất cả các query theo mặc định
},
},
});
function ProductDetails({ productId }) {
const { data: product } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
return (
<div style={{"border": "1px solid #ccc", "padding": "15px", "margin": "10px 0"}}>
<h3>{product.name}</h3>
<p>Giá: ${product.price.toFixed(2)}</p>
<p>{product.description}</p>
</div>
);
}
function ProductList() {
const handleProductHover = (productId) => {
// Tìm nạp trước dữ liệu khi người dùng di chuột qua liên kết sản phẩm
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductById(productId),
});
console.log(`Đang tìm nạp trước sản phẩm ${productId}`);
};
return (
<div>
<h2>Sản phẩm có sẵn:</h2>
<ul>
<li>
<a href="#" onMouseEnter={() => handleProductHover('A001')}
onClick={(e) => { e.preventDefault(); /* Điều hướng hoặc hiển thị chi tiết */ }}
>Global Widget X (A001)</a>
</li>
<li>
<a href="#" onMouseEnter={() => handleProductHover('B002')}
onClick={(e) => { e.preventDefault(); /* Điều hướng hoặc hiển thị chi tiết */ }}
>Universal Gadget Y (B002)</a>
</li>
</ul>
<p>Di chuột qua liên kết sản phẩm để xem prefetching hoạt động. Mở tab network để quan sát.</p>
</div>
);
}
function App() {
const [showProductA, setShowProductA] = React.useState(false);
const [showProductB, setShowProductB] = React.useState(false);
return (
<QueryClientProvider client={queryClient}>
<h1>Tìm nạp trước với React Suspense (React Query)</h1>
<ProductList />
<button onClick={() => setShowProductA(true)}>Hiển thị Global Widget X</button>
<button onClick={() => setShowProductB(true)}>Hiển thị Universal Gadget Y</button>
{showProductA && (
<Suspense fallback={<p>Đang tải Global Widget X...</p>}>
<ProductDetails productId="A001" />
</Suspense>
)}
{showProductB && (
<Suspense fallback={<p>Đang tải Universal Gadget Y...</p>}>
<ProductDetails productId="B002" />
</Suspense>
)}
</QueryClientProvider>
);
}
Trong ví dụ này, việc di chuột qua một liên kết sản phẩm sẽ kích hoạt `queryClient.prefetchQuery`, bắt đầu quá trình tìm nạp dữ liệu ở chế độ nền. Nếu người dùng sau đó nhấp vào nút để hiển thị chi tiết sản phẩm, và dữ liệu đã có trong cache từ lần tìm nạp trước, component sẽ render ngay lập tức mà không bị tạm dừng. Nếu việc tìm nạp trước vẫn đang diễn ra hoặc chưa được bắt đầu, Suspense sẽ hiển thị fallback cho đến khi dữ liệu sẵn sàng.
Xử Lý Lỗi với Suspense và Ranh Giới Lỗi (Error Boundaries)
Trong khi Suspense xử lý trạng thái 'đang tải' bằng cách hiển thị một fallback, nó không trực tiếp xử lý các trạng thái 'lỗi'. Nếu một promise được ném ra bởi một component đang tạm dừng bị từ chối (tức là tìm nạp dữ liệu thất bại), lỗi này sẽ lan truyền lên cây component. Để xử lý các lỗi này một cách duyên dáng và hiển thị một UI phù hợp, bạn cần sử dụng Ranh Giới Lỗi (Error Boundaries).
Một Ranh Giới Lỗi là một component React triển khai một trong hai phương thức vòng đời componentDidCatch
hoặc static getDerivedStateFromError
. Nó bắt các lỗi JavaScript ở bất cứ đâu trong cây component con của nó, bao gồm cả các lỗi được ném ra bởi các promise mà Suspense thường sẽ bắt nếu chúng đang chờ xử lý.
import React, { Suspense, useState } from 'react';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
// --- Component Ranh Giới Lỗi --- //
class MyErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
// Cập nhật state để lần render tiếp theo sẽ hiển thị UI fallback.
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Bạn cũng có thể ghi lại lỗi vào một dịch vụ báo cáo lỗi
console.error("Đã bắt được một lỗi:", error, errorInfo);
}
render() {
if (this.state.hasError) {
// Bạn có thể render bất kỳ UI fallback tùy chỉnh nào
return (
<div style={{"border": "2px solid red", "padding": "20px", "margin": "20px 0", "background": "#ffe0e0"}}>
<h2>Đã có lỗi xảy ra!</h2>
<p>{this.state.error && this.state.error.message}</p>
<p>Vui lòng thử làm mới trang hoặc liên hệ hỗ trợ.</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>Thử Lại</button>
</div>
);
}
return this.props.children;
}
}
// --- Tìm Nạp Dữ Liệu (có khả năng xảy ra lỗi) --- //
const fetchItemById = async (id) => {
console.log(`Đang cố gắng tìm nạp mục ${id}...`);
return new Promise((resolve, reject) => setTimeout(() => {
if (id === 'error-item') {
reject(new Error('Không thể tải mục: Mạng không thể truy cập hoặc không tìm thấy mục.'));
} else if (id === 'slow-item') {
resolve({ id: 'slow-item', name: 'Được Giao Chậm', data: 'Mục này mất một lúc nhưng đã đến!', status: 'success' });
} else {
resolve({ id, name: `Mục ${id}`, data: `Dữ liệu cho mục ${id}` });
}
}, id === 'slow-item' ? 3000 : 800));
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
retry: false, // Để minh họa, vô hiệu hóa thử lại để lỗi xuất hiện ngay lập tức
},
},
});
function DisplayItem({ itemId }) {
const { data: item } = useQuery({
queryKey: ['item', itemId],
queryFn: () => fetchItemById(itemId),
});
return (
<div>
<h3>Chi tiết mục:</h3>
<p>ID: {item.id}</p>
<p>Tên: {item.name}</p>
<p>Dữ liệu: {item.data}</p>
</div>
);
}
function App() {
const [fetchType, setFetchType] = useState('normal-item');
return (
<QueryClientProvider client={queryClient}>
<h1>Suspense và Ranh Giới Lỗi</h1>
<div>
<button onClick={() => setFetchType('normal-item')}>Tìm Nạp Mục Bình Thường</button>
<button onClick={() => setFetchType('slow-item')}>Tìm Nạp Mục Chậm</button>
<button onClick={() => setFetchType('error-item')}>Tìm Nạp Mục Gây Lỗi</button>
</div>
<MyErrorBoundary>
<Suspense fallback={<p>Đang tải mục qua Suspense...</p>}>
<DisplayItem itemId={fetchType} />
</Suspense>
</MyErrorBoundary>
</QueryClientProvider>
);
}
Bằng cách bọc ranh giới Suspense của bạn (hoặc các component có thể tạm dừng) bằng một Ranh Giới Lỗi, bạn đảm bảo rằng các lỗi mạng hoặc lỗi máy chủ trong quá trình tìm nạp dữ liệu được bắt và xử lý một cách duyên dáng, ngăn chặn toàn bộ ứng dụng bị sập. Điều này cung cấp một trải nghiệm mạnh mẽ và thân thiện với người dùng, cho phép người dùng hiểu vấn đề và có khả năng thử lại.
Quản Lý Trạng Thái và Vô Hiệu Hóa Dữ Liệu với Suspense
Điều quan trọng cần làm rõ là React Suspense chủ yếu giải quyết trạng thái tải ban đầu của các tài nguyên bất đồng bộ. Nó không vốn có khả năng quản lý cache phía client, xử lý việc vô hiệu hóa dữ liệu, hoặc điều phối các mutation (các hoạt động tạo, cập nhật, xóa) và các cập nhật UI sau đó.
Đây là lúc các thư viện tìm nạp dữ liệu hỗ trợ Suspense (React Query, SWR, Apollo Client, Relay) trở nên không thể thiếu. Chúng bổ sung cho Suspense bằng cách cung cấp:
- Caching Mạnh Mẽ: Chúng duy trì một cache trong bộ nhớ tinh vi của dữ liệu đã tìm nạp, phục vụ nó ngay lập tức nếu có, và xử lý việc xác thực lại ở chế độ nền.
- Vô Hiệu Hóa và Tìm Nạp Lại Dữ Liệu: Chúng cung cấp các cơ chế để đánh dấu dữ liệu đã cache là 'cũ' và tìm nạp lại nó (ví dụ: sau một mutation, một tương tác của người dùng, hoặc khi cửa sổ được focus).
- Cập Nhật Lạc Quan (Optimistic Updates): Đối với các mutation, chúng cho phép bạn cập nhật UI ngay lập tức (một cách lạc quan) dựa trên kết quả mong đợi của một lệnh gọi API, và sau đó quay lại nếu lệnh gọi API thực tế thất bại.
- Đồng Bộ Hóa Trạng Thái Toàn Cục: Chúng đảm bảo rằng nếu dữ liệu thay đổi từ một phần của ứng dụng của bạn, tất cả các component hiển thị dữ liệu đó sẽ tự động được cập nhật.
- Trạng Thái Tải và Lỗi cho Mutations: Trong khi
useQuery
có thể tạm dừng,useMutation
thường cung cấp các trạng tháiisLoading
vàisError
cho chính quá trình mutation, vì các mutation thường mang tính tương tác và yêu cầu phản hồi ngay lập tức.
Nếu không có một thư viện tìm nạp dữ liệu mạnh mẽ, việc triển khai các tính năng này trên một trình quản lý tài nguyên Suspense thủ công sẽ là một công việc đáng kể, về cơ bản yêu cầu bạn phải xây dựng framework tìm nạp dữ liệu của riêng mình.
Những Cân Nhắc Thực Tế và Các Phương Pháp Tốt Nhất
Việc áp dụng Suspense cho tìm nạp dữ liệu là một quyết định kiến trúc quan trọng. Dưới đây là một số cân nhắc thực tế cho một ứng dụng toàn cầu:
1. Không Phải Tất Cả Dữ Liệu Đều Cần Suspense
Suspense lý tưởng cho dữ liệu quan trọng ảnh hưởng trực tiếp đến việc render ban đầu của một component. Đối với dữ liệu không quan trọng, các lần tìm nạp nền, hoặc dữ liệu có thể được tải một cách lười biếng mà không có tác động trực quan mạnh mẽ, useEffect
truyền thống hoặc pre-rendering vẫn có thể phù hợp. Việc lạm dụng Suspense có thể dẫn đến một trải nghiệm tải ít chi tiết hơn, vì một ranh giới Suspense duy nhất chờ đợi *tất cả* các con của nó được giải quyết.
2. Độ Chi Tiết của các Ranh Giới Suspense
Hãy đặt các ranh giới <Suspense>
của bạn một cách cẩn thận. Một ranh giới lớn duy nhất ở đầu ứng dụng của bạn có thể che giấu toàn bộ trang sau một spinner, điều này có thể gây khó chịu. Các ranh giới nhỏ hơn, chi tiết hơn cho phép các phần khác nhau của trang tải độc lập, cung cấp một trải nghiệm tiến bộ và đáp ứng tốt hơn. Ví dụ, một ranh giới xung quanh component hồ sơ người dùng, và một ranh giới khác xung quanh danh sách các sản phẩm được đề xuất.
<div>
<h1>Trang Sản Phẩm</h1>
<Suspense fallback={<p>Đang tải chi tiết sản phẩm chính...</p>}>
<ProductDetails id="prod123" />
</Suspense>
<hr />
<h2>Sản Phẩm Liên Quan</h2>
<Suspense fallback={<p>Đang tải các sản phẩm liên quan...</p>}>
<RelatedProducts category="electronics" />
</Suspense>
</div>
Cách tiếp cận này có nghĩa là người dùng có thể xem chi tiết sản phẩm chính ngay cả khi các sản phẩm liên quan vẫn đang tải.
3. Render Phía Máy Chủ (SSR) và Streaming HTML
Các API SSR streaming mới của React 18 (renderToPipeableStream
) tích hợp đầy đủ với Suspense. Điều này cho phép máy chủ của bạn gửi HTML ngay khi nó sẵn sàng, ngay cả khi các phần của trang (như các component phụ thuộc vào dữ liệu) vẫn đang tải. Máy chủ có thể stream một placeholder (từ fallback của Suspense) và sau đó stream nội dung thực tế khi dữ liệu được giải quyết, mà không yêu cầu một lần render lại hoàn toàn phía client. Điều này cải thiện đáng kể hiệu suất tải cảm nhận được cho người dùng toàn cầu trên các điều kiện mạng khác nhau.
4. Áp Dụng Tăng Dần
Bạn không cần phải viết lại toàn bộ ứng dụng của mình để sử dụng Suspense. Bạn có thể giới thiệu nó một cách tăng dần, bắt đầu với các tính năng hoặc component mới sẽ được hưởng lợi nhiều nhất từ các mẫu tải khai báo của nó.
5. Công Cụ và Gỡ Lỗi
Mặc dù Suspense đơn giản hóa logic component, việc gỡ lỗi có thể khác. React DevTools cung cấp thông tin chi tiết về các ranh giới Suspense và trạng thái của chúng. Hãy làm quen với cách thư viện tìm nạp dữ liệu bạn chọn hiển thị trạng thái nội bộ của nó (ví dụ: React Query Devtools).
6. Thời Gian Chờ cho các Fallback của Suspense
Đối với thời gian tải rất dài, bạn có thể muốn giới thiệu một thời gian chờ cho fallback của Suspense, hoặc chuyển sang một chỉ báo tải chi tiết hơn sau một khoảng trễ nhất định. Các hook useDeferredValue
và useTransition
trong React 18 có thể giúp quản lý các trạng thái tải tinh tế hơn này, cho phép bạn hiển thị một phiên bản 'cũ' của UI trong khi dữ liệu mới đang được tìm nạp, hoặc trì hoãn các cập nhật không khẩn cấp.
Tương Lai của Việc Tìm Nạp Dữ Liệu trong React: Component React Phía Server và Hơn Nữa
Hành trình tìm nạp dữ liệu trong React không dừng lại ở Suspense phía client. Component React Phía Server (RSC) đại diện cho một sự tiến hóa đáng kể, hứa hẹn sẽ làm mờ ranh giới giữa client và server, và tối ưu hóa hơn nữa việc tìm nạp dữ liệu.
- Component React Phía Server (RSC): Các component này render trên server, tìm nạp dữ liệu của chúng trực tiếp, và sau đó chỉ gửi HTML và JavaScript cần thiết phía client đến trình duyệt. Điều này loại bỏ các thác nước phía client, giảm kích thước gói, và cải thiện hiệu suất tải ban đầu. RSC hoạt động song song với Suspense: các component server có thể tạm dừng nếu dữ liệu của chúng chưa sẵn sàng, và server có thể stream một fallback Suspense xuống client, sau đó được thay thế khi dữ liệu được giải quyết. Đây là một yếu tố thay đổi cuộc chơi cho các ứng dụng có yêu cầu dữ liệu phức tạp, mang lại một trải nghiệm liền mạch và hiệu suất cao, đặc biệt có lợi cho người dùng ở các khu vực địa lý khác nhau với độ trễ khác nhau.
- Tìm Nạp Dữ Liệu Thống Nhất: Tầm nhìn dài hạn cho React bao gồm một cách tiếp cận thống nhất để tìm nạp dữ liệu, nơi framework cốt lõi hoặc các giải pháp tích hợp chặt chẽ cung cấp hỗ trợ hàng đầu cho việc tải dữ liệu cả trên server và client, tất cả được điều phối bởi Suspense.
- Sự Tiến Hóa Liên Tục của Thư Viện: Các thư viện tìm nạp dữ liệu sẽ tiếp tục phát triển, cung cấp các tính năng tinh vi hơn nữa cho caching, vô hiệu hóa, và cập nhật thời gian thực, xây dựng trên các khả năng nền tảng của Suspense.
Khi React tiếp tục trưởng thành, Suspense sẽ là một phần ngày càng trung tâm của câu đố để xây dựng các ứng dụng hiệu suất cao, thân thiện với người dùng và dễ bảo trì. Nó thúc đẩy các nhà phát triển hướng tới một cách xử lý các hoạt động bất đồng bộ mang tính khai báo và linh hoạt hơn, chuyển sự phức tạp từ các component riêng lẻ vào một lớp dữ liệu được quản lý tốt.
Kết luận
React Suspense, ban đầu là một tính năng để tách mã, đã phát triển thành một công cụ biến đổi cho việc tìm nạp dữ liệu. Bằng cách áp dụng mẫu Fetch-As-You-Render và tận dụng các thư viện hỗ trợ Suspense, các nhà phát triển có thể cải thiện đáng kể trải nghiệm người dùng của ứng dụng, loại bỏ các thác nước tải, đơn giản hóa logic component và cung cấp các trạng thái tải mượt mà, phối hợp. Kết hợp với Ranh Giới Lỗi để xử lý lỗi mạnh mẽ và lời hứa trong tương lai của Component React Phía Server, Suspense trao quyền cho chúng ta xây dựng các ứng dụng không chỉ hiệu suất và linh hoạt mà còn thú vị hơn cho người dùng trên toàn cầu. Việc chuyển sang một mô hình tìm nạp dữ liệu do Suspense điều khiển đòi hỏi một sự điều chỉnh về mặt khái niệm, nhưng những lợi ích về sự rõ ràng của mã, hiệu suất và sự hài lòng của người dùng là đáng kể và xứng đáng với sự đầu tư.