Memory leak (rò rỉ bộ nhớ) trong React xảy ra khi component không dọn dẹp tài nguyên sau khi unmount, dẫn đến việc bộ nhớ bị chiếm dụng vĩnh viễn. Dù React có cơ chế quản lý vòng đời component, việc không tuân thủ nguyên tắc cleanup sẽ gây ra rò rỉ. Bài viết này tập trung vào các nguyên nhân phổ biến trong React và cách khắc phục chi tiết qua ví dụ thực tế.
1. Nguyên nhân
a. Subscriptions/Event Listeners không được cleanup trong useEffect
Ví dụ: Component đăng ký một WebSocket hoặc event từ thư viện bên ngoài nhưng quên hủy đăng ký khi unmount.
function RealTimeFeed() {
const [messages, setMessages] = useState([])
useEffect(() => {
// Đăng ký WebSocket
const socket = new WebSocket("wss://api.chat.com")
socket.onmessage = (event) => {
setMessages((prev) => [...prev, event.data])
}
// ❌ Quên đóng kết nối khi component unmount
}, [])
return <div>{/* Render messages */}</div>
}
Phân tích:
- Khi component unmount, WebSocket vẫn mở và tiếp tục nhận dữ liệu →
setMessages
được gọi trên component đã unmount → Memory leak. - Hậu quả: Ứng dụng ngốn RAM, có thể crash sau thời gian dài.
Cách Sửa: Thêm cleanup function để đóng kết nối:
useEffect(() => {
const socket = new WebSocket("wss://api.chat.com")
socket.onmessage = (event) => {
/* ... */
}
return () => {
socket.close() // ✅ Đóng kết nối khi unmount
}
}, [])
b. Async Operations không được hủy đúng cách
Ví dụ: Fetch dữ liệu từ API nhưng không hủy request nếu component unmount trước khi hoàn thành.
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((response) => response.json())
.then((data) => setUser(data)) // ❌ Cập nhật state sau khi unmount
}, [userId])
return <div>{/* Render user */}</div>
}
Phân Tích:
- Nếu người dùng rời khỏi trang trước khi fetch hoàn thành →
setUser
cập nhật state của component đã unmount → Memory leak. - Hậu quả: Cảnh báo "Can't perform a React state update on an unmounted component" và rò rỉ bộ nhớ.
Cách Sửa: Sử dụng AbortController
để hủy request:
useEffect(() => {
const abortController = new AbortController()
fetch(`/api/users/${userId}`, {
signal: abortController.signal,
})
.then(/* ... */)
.catch((error) => {
if (error.name !== "AbortError") console.error(error)
})
return () => {
abortController.abort() // ✅ Hủy request khi unmount
}
}, [userId])
c. Giữ tham chiếu đến dữ liệu lớn trong state/ref
Ví dụ: Lưu trữ dữ liệu lớn (như hình ảnh, file) trong state mà không giải phóng khi không cần.
function ImageViewer() {
const [imageData, setImageData] = useState(null)
const loadImage = async () => {
const data = await fetchLargeImage() // Dữ liệu ảnh lớn (10MB+)
setImageData(data)
}
// ❌ imageData vẫn tồn tại ngay cả khi component unmount
return <div>{/* Render ảnh */}</div>
}
Phân Tích:
- Khi component unmount,
imageData
vẫn được giữ trong bộ nhớ → Không được garbage collected → Memory leak.
Cách Sửa: Reset state khi unmount:
useEffect(() => {
return () => {
setImageData(null) // ✅ Giải phóng dữ liệu khi unmount
}
}, [])
2. Các công cụ phát hiện Memory Leak trong React
a. React Developer Tools
- Component Tab: Kiểm tra xem component có bị mount nhiều lần không mong muốn không.
- Profiler: Ghi lại quá trình render để phát hiện component re-render liên tục do state leak.
b. Chrome DevTools
- Memory Tab: Chụp heap snapshot trước và sau khi thao tác với component để tìm đối tượng bị rò rỉ.
- Performance Tab: Theo dõi bộ nhớ theo thời gian thực, phát hiện tăng đột biến khi component unmount.
3. Case Study: Memory Leak trong quản lý form
Bài Toán: Form đăng ký sử dụng thư viện bên ngoài để validate, nhưng không hủy instance validate khi unmount.
Code Ban Đầu:
function RegistrationForm() {
const [email, setEmail] = useState("")
const emailRef = useRef(null)
useEffect(() => {
// Khởi tạo validator từ thư viện bên ngoài
const validator = new ExternalValidator(emailRef.current, {
rules: { email: "required|email" },
})
// ❌ Quên hủy validator
}, [])
return <input ref={emailRef} value={email} onChange={(e) => setEmail(e.target.value)} />
}
Nguyên Nhân:
- Khi component unmount,
validator
vẫn giữ tham chiếu đến DOM element (emailRef.current
) → DOM element không được giải phóng.
Cách Sửa:
useEffect(() => {
const validator = new ExternalValidator(emailRef.current, {
/* ... */
})
return () => {
validator.destroy() // ✅ Hủy validator và dọn dẹp DOM
}
}, [])
4. Best Practices để tránh Memory Leak trong React
a. Luôn dọn dẹp trong useEffect
- Event listeners, subscriptions, timers: Luôn hủy trong cleanup function.
- Async operations: Sử dụng
AbortController
hoặc biến flag (isMounted
).
b. Tránh lưu trữ dữ liệu không cần thiết
- State: Reset state khi component unmount nếu dữ liệu quá lớn.
- Ref: Đặt
ref.current = null
trong cleanup nếu ref giữ tài nguyên (DOM element, third-party instances).
c. Sử dụng useMemo/useCallback đúng cách
- Tránh tạo function/object mới không cần thiết → Giảm re-render và rò rỉ tiềm ẩn.
const fetchData = useCallback(async () => {
// Logic fetch
}, [dependencies])
useEffect(() => {
fetchData()
}, [fetchData])
d. Kiểm tra thư viện bên thứ ba
- Đảm bảo thư viện cung cấp cơ chế hủy (ví dụ:
unsubscribe()
,destroy()
).
Kết luận
Memory leak trong React thường xuất phát từ việc không tuân thủ nguyên tắc cleanup resources. Bằng cách:
- Luôn thêm cleanup function trong
useEffect
. - Sử dụng
AbortController
cho async operations. - Kiểm soát dữ liệu lớn trong state/ref, bạn có thể xây dựng ứng dụng ổn định và tiết kiệm tài nguyên.
Hãy coi cleanup là một phần không thể thiếu trong mỗi useEffect
– nó không chỉ ngăn memory leak mà còn giúp code của bạn dễ bảo trì hơn.