Để lấy dữ liệu (fetch data) trong React, hãy bắt đầu request bên trong useEffect, lưu response vào state và render thông báo đang tải cho đến khi dữ liệu về. Đưa các giá trị mà request phụ thuộc vào, như một id, vào mảng dependency để effect fetch lại khi chúng thay đổi.
Các ví dụ trên trang này không thể truy cập mạng, nên fetchUser là một API giả: một promise resolve sau một khoảng trễ, giống cách fetch hoạt động. Đổi 800 thành 3000 để thấy dòng chữ đang tải hiện lâu hơn.
Với một API thật, thân effect trông như sau:
useEffect(() => {
fetch('https://api.example.com/users/1')
.then((res) => res.json())
.then((data) => setUser(data));
}, []);
Đang tải, lỗi và dữ liệu
Một request có ba kết cục mà màn hình cần thể hiện: vẫn đang tải, thất bại, hoặc đã xong. Giữ từng trạng thái trong state, và đặt lại chúng khi effect bắt đầu một request mới.
Click User 3: API giả reject, thông báo lỗi thay cho tên người dùng, và console hiện lỗi đó. Khối finally tắt loading ở cả hai nhánh, nên một request thất bại không bao giờ để biểu tượng đang tải quay mãi.
Khi fetch làm việc với máy chủ thật, response 404 hay 500 không làm promise bị reject. Hãy kiểm tra res.ok và tự ném lỗi:
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
async/await bên trong effect
Hàm bạn truyền vào useEffect không thể là async. React mong nó trả về không gì cả hoặc một hàm cleanup, còn hàm async luôn trả về một promise.
// Wrong: the effect returns a promise
useEffect(async () => {
const data = await fetchUser(id);
setUser(data);
}, [id]);
// Right: define an async function inside and call it
useEffect(() => {
async function load() {
const data = await fetchUser(id);
setUser(data);
}
load();
}, [id]);
Race condition
Khi id thay đổi nhanh, có hai request đang chạy cùng lúc. Không gì đảm bảo chúng trả lời theo đúng thứ tự. Nếu request cũ chậm hơn, nó đến sau cùng và ghi đè dữ liệu của id mà người dùng vừa chọn gần nhất.
Cách sửa là một cờ ignore. Mỗi lần chạy effect có cờ riêng, và cleanup của lần đó đặt cờ thành true. Vì React chạy cleanup trước lần chạy kế tiếp, response từ một lần chạy đã lỗi thời sẽ thấy ignore === true và bị bỏ qua. Khối code này render cùng một profile hai lần, không có cờ và có cờ:
Click nút rồi chờ. Câu trả lời nhanh của user 2 đến trước, nên cả hai profile đều ghi log và giữ Grace. Khoảng một giây rưỡi sau cú click, câu trả lời chậm của user 1 mới đến: profile không có cờ chuyển sang Ada dù user 2 đang được chọn, còn profile có cờ ghi dropped stale Ada và giữ nguyên Grace.
Cùng một bug này xuất hiện ở ô tìm kiếm, các tab và mọi danh sách được lọc ở máy chủ. Ví dụ đang tải ở trên bỏ cờ đi cho ngắn gọn, nhưng mọi lần fetch trong effect đều nên có nó, hoặc dùng cách hủy request bên dưới.
Hủy request bằng AbortController
Cờ ignore bỏ qua response lỗi thời, nhưng request vẫn chạy đến cuối. Với fetch thật, bạn có thể hủy nó. Tạo một AbortController trong effect, truyền signal của nó vào fetch, và gọi abort() trong cleanup:
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setUser(await res.json());
} catch (err) {
if (err.name === 'AbortError') return; // cancelled on purpose
setError(err);
}
}
load();
return () => controller.abort();
}, [id]);
Một fetch bị hủy sẽ reject với AbortError, nên khối catch bỏ qua nó thay vì hiển thị như một lỗi. Abort cũng xử lý luôn trường hợp unmount: khi component bị gỡ khỏi màn hình, cleanup của nó hủy request.
Chuyển vào một custom hook
Trạng thái đang tải, lỗi và cơ chế chống race đều giống nhau ở mọi component có fetch. Hãy đưa chúng vào một custom hook và mỗi component chỉ cần một dòng để lấy dữ liệu.
fetchPosts được định nghĩa bên ngoài component, nên định danh của nó không bao giờ đổi và có thể đưa vào mảng dependency một cách an toàn. Nếu định nghĩa bên trong App, nó sẽ là một hàm mới sau mỗi lần render, effect sẽ chạy sau mỗi lần render, và vì effect có đặt state nên nó sẽ fetch không bao giờ dừng.
Đổi 500 thành 2000 rồi click nút hai lần liên tiếp: console hiện hai request, và chỉ các bài viết của chủ đề bạn chọn cuối cùng được hiển thị.
Fetch trong trình xử lý sự kiện
Effect dành cho dữ liệu mà component cần vì nó đang có mặt trên màn hình: trang profile tải profile của nó. Khi một request xảy ra vì người dùng đã làm gì đó, như nhấn Search hay Save, hãy gửi request trong trình xử lý sự kiện. Bạn biết chính xác điều gì đã kích hoạt nó, và không có gì chạy lại khi một state không liên quan thay đổi.
async function handleSubmit(e) {
e.preventDefault();
setStatus('saving');
const res = await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ text }) });
setStatus(res.ok ? 'saved' : 'error');
}
Lỗi thường gặp
Thiếu dependency. Một effect đọc id nhưng có mảng [] sẽ mãi fetch user đầu tiên. Hãy liệt kê mọi giá trị mà request dùng đến.
Fetch ngay trong thân component. Một lệnh fetch nằm ngoài effect sẽ chạy ở mỗi lần render, và nếu nó đặt state thì sẽ tạo ra vòng lặp.
Tin rằng fetch sẽ reject khi có lỗi. Nó chỉ reject khi mạng gặp sự cố. Hãy kiểm tra res.ok.
Quên đặt lại trạng thái đang tải. Khi id thay đổi, hãy đặt loading về true, nếu không dữ liệu cũ vẫn nằm trên màn hình mà không có dấu hiệu nào cho thấy dữ liệu mới đang đến.
Khi nào nên dùng thư viện hoặc framework
Fetch trong effect vẫn ổn với vài request. Nó không có cache: mở cùng một profile hai lần thì fetch hai lần. Nó không chia sẻ dữ liệu giữa các component, không thử lại khi thất bại, và không fetch lại khi tab được focus trở lại. Các thư viện làm những việc này giúp bạn:
import { useQuery } from '@tanstack/react-query';
function Profile({ id }) {
const { data, error, isPending } = useQuery({
queryKey: ['user', id],
queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
return <p>{data.name}</p>;
}
TanStack Query và SWR là những lựa chọn phổ biến cho dữ liệu phía máy khách. Framework còn đi xa hơn và tải dữ liệu trên máy chủ trước khi trang đến trình duyệt: Next.js với server component, React Router với loader. Cách này tránh được khoảnh khắc nháy trạng thái đang tải và chuỗi request nối đuôi nhau, xảy ra khi dữ liệu của component cha phải về xong thì component con mới bắt đầu fetch được.
React 19: use() với Suspense
React 19 bổ sung use, hàm đọc một promise trong lúc render. Component tạm dừng (suspend) cho đến khi promise resolve, và boundary <Suspense> gần nhất hiển thị fallback trong lúc chờ, nên bản thân component không cần trạng thái đang tải:
import { use, Suspense } from 'react';
function Profile({ userPromise }) {
const user = use(userPromise);
return <p>{user.name}</p>;
}
<Suspense fallback={<p>Loading...</p>}>
<Profile userPromise={userPromise} />
</Suspense>
Promise phải được tạo bên ngoài component (bởi framework, một cache hoặc component cha), không phải bên trong lần render đọc nó, nếu không mỗi lần render sẽ bắt đầu một request mới. Trang về hook use trình bày phần này với các ví dụ chạy được.
Câu hỏi thường gặp
Làm sao để fetch dữ liệu khi một component React được tải?
Bắt đầu request trong một useEffect, đưa các giá trị mà request phụ thuộc vào mảng dependency ([] nếu không có), và lưu kết quả vào state bằng useState. Render thông báo đang tải cho đến khi dữ liệu về.
Vì sao callback của useEffect không thể là async?
Hàm async luôn trả về một promise, còn React mong effect trả về hoặc không gì cả, hoặc một hàm cleanup. Hãy viết một hàm async bên trong effect và gọi nó ngay.
Race condition khi fetch dữ liệu trong React là gì?
Khi đầu vào thay đổi nhanh, có hai request đang chạy cùng lúc và request cũ hơn có thể trả lời sau cùng, ghi đè dữ liệu mới hơn. Hãy đặt cờ ignore trong cleanup của effect và bỏ qua setState khi cờ được bật, hoặc hủy request bằng AbortController.
Nên dùng useEffect hay thư viện để fetch dữ liệu?
useEffect dùng tốt cho ứng dụng nhỏ và rất đáng để hiểu. Với cache, khử trùng lặp, thử lại và fetch lại, một thư viện như TanStack Query hay SWR, hoặc cơ chế tải dữ liệu có sẵn trong framework như Next.js, giúp bạn không phải tự viết phần logic đó.
Làm sao hiện biểu tượng đang tải trong lúc chờ dữ liệu?
Giữ một boolean loading (hoặc một chuỗi status) trong state, bật nó trước khi request bắt đầu và tắt khi request kết thúc, ở cả nhánh thành công lẫn nhánh lỗi. Render biểu tượng đang tải khi nó là true.