Chuyện gì đang xảy ra
Bạn mở console trình duyệt và thấy cảnh báo này xuất hiện khắp nơi:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
Hãy hình dung tình huống điển hình: một component khởi động một lệnh gọi fetch() — có thể mất 2–3 giây — và người dùng nhấn nút quay lại ở giữa chừng. Fetch vẫn hoàn thành. Callback gọi setUser(data) trên một component đã biến mất. React chặn lại và bỏ qua việc cập nhật state. Không có crash. Nhưng request đã hoàn tất, callback đã chạy, và bộ nhớ bị giữ lại vô ích. Nếu tình trạng này xảy ra trên đủ nhiều component, bạn sẽ có một memory leak thực sự.
Tái hiện vấn đề
Đây là đoạn code tối giản để kích hoạt lỗi:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // ← fires after unmount
}, [userId]);
return <div>{user?.name}</div>;
}
Unmount UserProfile trước khi fetch hoàn thành — nhấn nút quay lại, chuyển route, render có điều kiện — và setUser(data) sẽ tác động lên một component đã chết.
Cách xử lý 1: Hủy fetch bằng AbortController (khuyến nghị)
AbortController hủy luôn network request đang bay — không chỉ callback. Trình duyệt dừng request giữa chừng và ném ra một AbortError, mà bạn bắt lại rồi bỏ qua:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') return; // ignore cancellation
console.error(err);
});
return () => controller.abort(); // cleanup on unmount
}, [userId]);
return <div>{user?.name}</div>;
}
Hàm cleanup chạy khi component unmount, hoặc khi userId thay đổi trước khi request trước đó hoàn thành. controller.abort() cắt đứt fetch. AbortError được nuốt im lặng. Không có cập nhật state, không có cảnh báo, không lãng phí băng thông.
Cách xử lý 2: Flag isMounted (cho tác vụ async không phải fetch)
Một số tác vụ async không thể hủy được — callback của SDK bên thứ ba, Promise.all() bọc nhiều thao tác, hàm debounce. Với những trường hợp đó, hãy chặn việc cập nhật state bằng một cờ boolean:
function DataWidget() {
const [data, setData] = useState(null);
useEffect(() => {
let isMounted = true;
someAsyncOperation().then(result => {
if (isMounted) setData(result); // only update if still mounted
});
return () => {
isMounted = false; // cleanup: disable stale callbacks
};
}, []);
return <div>{data}</div>;
}
Tác vụ async vẫn chạy đến khi hoàn thành — chỉ là sẽ không đụng đến state nữa. Hãy coi đây là lớp bảo vệ an toàn, không phải giải pháp triệt để. Công việc vẫn diễn ra; bạn chỉ đang bỏ qua kết quả.
Cách xử lý 3: Timer và interval
Timer có lẽ là thủ phạm phổ biến thứ hai sau fetch. Một setInterval không được dọn dẹp sau khi unmount sẽ tiếp tục chạy vô thời hạn. Luôn lưu ID lại và xóa nó đi:
function LiveClock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval); // cleanup
}, []);
return <span>{time.toLocaleTimeString()}</span>;
}
Cách xử lý 4: Event listener và subscription
Đăng ký trong effect, hủy đăng ký trong cleanup — không có ngoại lệ:
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Subscription của RxJS tuân theo quy tắc tương tự:
useEffect(() => {
const subscription = dataStream$.subscribe(val => setData(val));
return () => subscription.unsubscribe();
}, []);
Cách xử lý 5: React Query / SWR (giải pháp lâu dài)
Viết boilerplate AbortController trên mỗi component fetch dữ liệu sẽ nhanh chóng trở nên mệt mỏi. React Query xử lý việc hủy, cache, loại bỏ trùng lặp và dọn dẹp tự động — toàn bộ vấn đề biến mất:
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json())
});
return <div>{user?.name}</div>;
}
Không cần cleanup thủ công. Không cần cờ. Nếu codebase của bạn có nhiều hơn một vài pattern fetch-trong-useEffect, việc chuyển sang React Query sẽ nhanh chóng mang lại lợi ích.
Xác nhận fix đã hoạt động
- Mở DevTools → Console.
- Điều hướng đến component hiển thị cảnh báo.
- Ngay lập tức điều hướng đi trước khi các thao tác async hoàn thành.
- Kiểm tra console — cảnh báo sẽ không còn xuất hiện nữa.
Muốn có tín hiệu rõ ràng hơn? Thêm một dòng log vào cleanup trong quá trình phát triển:
return () => {
console.log('cleanup ran — aborting fetch');
controller.abort();
};
Nếu "cleanup ran" xuất hiện trước khi nhận được response từ fetch, thì cleanup đã được kết nối đúng cách.
Lưu ý về React 18
React 18 đã bỏ cảnh báo này khỏi production build. Việc gọi setState trên một component đã unmount giờ sẽ bị bỏ qua im lặng — React sẽ không thông báo cho bạn nữa. Đừng nhầm lẫn giữa việc thiếu cảnh báo và thiếu lỗi. Các tác vụ async vẫn chạy, bộ nhớ vẫn bị giữ, và các callback cũ vẫn có thể gây ra hành vi bất ngờ. Hãy dọn dẹp effect của bạn dù đang dùng phiên bản React nào.
Những điểm cần nhớ
- Hãy viết hàm cleanup trước khi viết phần thân của effect. Điều này buộc bạn phải suy nghĩ về những gì cần hủy trước khi viết code khởi động chúng.
AbortControllerhủy bản thân network request — trình duyệt dừng việc truyền tải. FlagisMountedchỉ bỏ qua kết quả; request vẫn hoàn thành bình thường.- Timer sẽ chạy mãi mãi nếu bạn không xóa chúng. Chỉ một
setIntervalbị quên trong một modal có thể tiêu tốn đáng kể CPU sau một phiên làm việc dài. - Nếu bạn đang viết boilerplate cleanup trên mọi component, đó là dấu hiệu nên chuyển sang dùng React Query hoặc SWR và để thư viện quản lý vòng đời dữ liệu.

