Tối ưu hóa hiệu năng React với useMemo và useCallback: Khi nào nên dùng?
Trong quá trình phát triển các ứng dụng web quy mô lớn sử dụng React, vấn đề về hiệu năng luôn là mối quan tâm hàng đầu của các kỹ sư phần mềm. Một trong những nguyên nhân chính gây ra sự sụt giảm tốc độ là việc render lại các thành phần không cần thiết, thường được gọi là "unnecessary re-renders". Mặc dù React đã tối ưu hóa việc này thông qua Virtual DOM, nhưng khi dữ liệu phức tạp hoặc các hàm được truyền xuống các thành phần con, cơ chế mặc định của React vẫn có thể kích hoạt quá trình render lại toàn bộ cây thành phần nếu không có biện pháp can thiệp đúng lúc. Bài viết này sẽ đi sâu phân tích hai công cụ quan trọng trong hệ sinh thái React là useMemo và useCallback, giải thích cơ chế hoạt động, trường hợp áp dụng thực tế và những sai lầm phổ biến mà các lập trình viên thường gặp phải khi sử dụng chúng một cách mù quáng.
Khái niệm cốt lõi và cơ chế hoạt động của useMemo
Hook useMemo được thiết kế để lưu trữ kết quả tính toán của một hàm trong quá trình render, giúp tránh việc tính toán lại khi các phụ thuộc (dependencies) của nó không thay đổi. Về bản chất, đây là một kỹ thuật "memoization" giúp ghi nhớ giá trị đã tính sẵn. Khi một component được render, React sẽ chạy hàm truyền vào, nhưng nếu component này bị render lại mà mảng dependencies không thay đổi, React sẽ trả về giá trị đã lưu trữ trước đó thay vì thực thi lại hàm.
Tuy nhiên, cần hiểu rõ rằng useMemo chỉ tối ưu khi việc tính toán ban đầu là một thao tác tốn kém, chẳng hạn như xử lý một mảng dữ liệu khổng lồ, thực hiện các phép toán phức tạp, hoặc lọc dữ liệu từ một API response lớn. Nếu bạn áp dụng useMemo cho các phép tính đơn giản như cộng trừ số nguyên, chi phí để so sánh và quản lý dependencies đôi khi còn lớn hơn cả lợi ích mà nó mang lại. Dưới đây là ví dụ minh họa việc sử dụng useMemo để tối ưu hóa việc lọc danh sách sản phẩm:
import React, { useMemo, useState } from 'react';
const ProductList = ({ products, filterText }) => {
// Chỉ tính toán lại khi filterText thay đổi
const filteredProducts = useMemo(() => {
console.log('Đang lọc sản phẩm...');
return products.filter(product =>
product.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [products, filterText]);
return (
{filteredProducts.map(p => (
- {p.name}
))}
);
};
Trong đoạn mã trên, nếu filterText không thay đổi, React sẽ bỏ qua việc gọi hàm filter dù component ProductList có thể bị re-render vì lý do khác (ví dụ: một state cha thay đổi). Điều này đảm bảo tính toán chỉ diễn ra khi thực sự cần thiết.
Vai trò của useCallback trong việc ổn định tham chiếu hàm
Khác với useMemo tập trung vào giá trị, useCallback tập trung vào việc lưu trữ chính hàm đó để đảm bảo tham chiếu (reference) của hàm không thay đổi giữa các lần render. Trong React, khi một hàm được tạo mới trong thân component mỗi lần render, nó sẽ là một đối tượng mới với địa chỉ bộ nhớ khác. Nếu hàm này được truyền xuống các component con, và các component con đó được khai báo bằng React.memo, việc tham chiếu hàm thay đổi sẽ khiến React.memo thất bại và kích hoạt render lại con cháu, bất chấp các props khác không đổi.
Việc sử dụng useCallback giúp "đóng băng" hàm đó trừ khi một trong các phụ thuộc của nó thay đổi. Điều này cực kỳ hữu ích khi hàm được sử dụng làm props cho các component con đã được tối ưu hóa hoặc khi hàm được dùng trong các hook khác như useEffect để tránh tạo ra các vòng lặp vô hạn. Dưới đây là ví dụ về cách sử dụng useCallback để ngăn chặn re-render không cần thiết của một button con:
import React, { useCallback, useState } from 'react';
const ChildButton = React.memo(({ onClick }) => {
console.log('ChildButton đang render');
return ;
});
const ParentComponent = () => {
const [count, setCount] = useState(0);
const [otherState, setOtherState] = useState(false);
// Hàm này sẽ không thay đổi tham chiếu khi otherState thay đổi
const handleClick = useCallback(() => {
setCount(prev => prev + 1);
console.log('Hàm handleClick được gọi');
}, [setCount]);
const handleToggle = () => setOtherState(prev => !prev);
return (
Count: {count}
);
};
Trong ví dụ này, nếu ta không dùng useCallback, mỗi lần nhấn nút "Toggle State khác", hàm handleClick sẽ được tạo mới hoàn toàn. Điều này khiến component ChildButton (dù được bọc React.memo) vẫn phải render lại vì props onClick đã thay đổi tham chiếu. Khi dùng useCallback, hàm handleClick giữ nguyên tham chiếu, giúp ChildButton hoàn toàn im lặng khi state khác thay đổi.
Những sai lầm thường gặp và lời khuyên tối ưu
Một quan niệm sai lầm phổ biến trong cộng đồng phát triển React là "sử dụng useMemo và useCallback ở mọi nơi để tăng tốc". Thực tế, việc lạm dụng các hook này có thể gây phản tác dụng. Mỗi lần React gọi useMemo hoặc useCallback, nó phải thực hiện thêm các bước kiểm tra và so sánh dependencies, tiêu tốn tài nguyên CPU. Nếu hàm tính toán quá đơn giản, chi phí này sẽ lớn hơn lợi ích thu được. Ngoài ra, nếu mảng dependencies quá lớn hoặc chứa các đối tượng phức tạp, việc so sánh sự bằng nhau của chúng cũng có thể trở nên tốn kém, đặc biệt khi React so sánh bằng tham chiếu đối tượng thay vì giá trị sâu.
Quy tắc vàng là chỉ nên sử dụng useMemo khi có một hàm tính toán nặng hoặc khi cần ngăn chặn re-render của component con dựa trên giá trị. Tương tự, useCallback chỉ nên dùng khi bạn truyền hàm đó xuống một component con đã được tối ưu bằng React.memo hoặc khi sử dụng hàm đó trong useEffect với dependencies cụ thể. Việc sử dụng console.log để theo dõi số lần render là công cụ hiệu quả nhất để xác định xem bạn có thực sự cần tối ưu hay không trước khi thêm vào các hook phức tạp này.
Tóm lại, việc làm chủ useMemo và useCallback không chỉ giúp ứng dụng của bạn chạy mượt mà hơn mà còn thể hiện tư duy kỹ thuật sâu sắc về cách React quản lý trạng thái và vòng đời component. Hãy luôn ưu tiên viết mã sạch, dễ đọc và chỉ can thiệp vào quá trình render khi có bằng chứng về hiệu năng kém, tránh tối ưu hóa trước khi thực sự cần thiết.