useMemo và useCallback trong React: khi nào thực sự cần
1. Tổng quan
useMemo và useCallback thường bị dùng theo cảm tính: hoặc bọc mọi thứ “cho chắc”, hoặc không bao giờ dùng. Cả hai cách đều sai. Bài này giải thích cơ chế đằng sau hai hook, chỉ ra ba tình huống chúng thực sự có ích và những trường hợp nên bỏ qua. Bạn cũng sẽ viết một test để chứng minh việc memo hóa có tác dụng thật.
2. Vấn đề gốc: mỗi lần render là một lần tạo mới
Mỗi khi component render lại, toàn bộ thân hàm chạy lại từ đầu. Mọi object, array và function khai báo bên trong đều được tạo mới.
function Checkout({ customer }) {
const shipping = { city: customer.city, express: true };
const handlePay = () => console.log('Thanh toán cho', customer.name);
return <PaymentForm shipping={shipping} onPay={handlePay} />;
}
Sau mỗi lần Checkout render, shipping và handlePay là những tham chiếu mới, dù nội dung không đổi. JavaScript so sánh object và function theo tham chiếu:
{ city: 'Huế' } === { city: 'Huế' } // false
(() => 1) === (() => 1) // false
Thường thì điều này vô hại, vì tạo object rất rẻ. Nó chỉ thành vấn đề khi có thứ gì đó so sánh tham chiếu. Đó có thể là React.memo, mảng dependency của useEffect, hoặc một hook khác. useMemo và useCallback sinh ra để giữ tham chiếu ổn định giữa các lần render.
3. useMemo: ghi nhớ một giá trị
useMemo(calculate, deps) gọi calculate ở lần render đầu và lưu kết quả lại. Ở các lần sau, React chỉ tính lại khi một phần tử trong deps thay đổi, so sánh bằng Object.is.
3.1. Tránh tính toán đắt mỗi lần render
import { useMemo, useState } from 'react';
function OrderReport({ orders }) {
const [keyword, setKeyword] = useState('');
const [darkMode, setDarkMode] = useState(false);
const visibleOrders = useMemo(() => {
const kw = keyword.toLowerCase();
return orders
.filter((o) => o.customer.toLowerCase().includes(kw))
.sort((a, b) => b.total - a.total);
}, [orders, keyword]);
return (
<div className={darkMode ? 'dark' : ''}>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button onClick={() => setDarkMode((d) => !d)}>Đổi giao diện</button>
<ul>
{visibleOrders.map((o) => (
<li key={o.id}>{o.customer}: {o.total}</li>
))}
</ul>
</div>
);
}
Giải thích:
- Phép
filtervàsortchỉ chạy lại khiordershoặckeywordđổi. - Khi bạn bấm “Đổi giao diện”,
darkModeđổi và component render lại. Lúc nàyuseMemotrả về mảng đã lưu, không lọc lại. sortan toàn ở đây vìfilterđã trả về mảng mới. Không có thao tác nào sửa trực tiếp vào proporders.
Làm sao biết phép tính có “đắt” không? Hãy đo thay vì đoán:
console.time('filter orders');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('filter orders');
filter orders: 0.08ms
Một mốc thực dụng: nếu phép tính tốn dưới khoảng 1ms trên máy dev thì gần như không cần memo. Nhớ đo ở production build, và tốt hơn nữa là bật CPU throttling trong DevTools, vì máy người dùng thường chậm hơn máy bạn.
3.2. Giữ object ổn định để truyền đi
Trường hợp thứ hai không liên quan đến tốc độ tính toán. Ở đây bạn cần một object có tham chiếu ổn định để làm dependency:
function CustomerOrders({ customerId, status }) {
const [orders, setOrders] = useState([]);
const query = useMemo(() => ({ customerId, status }), [customerId, status]);
useEffect(() => {
fetchOrders(query).then(setOrders);
}, [query]);
return <OrderTable orders={orders} />;
}
Nếu bỏ useMemo, query sẽ là object mới sau mỗi render. Khi đó effect chạy lại liên tục, và vì setOrders gây render tiếp nên bạn có thể rơi vào vòng gọi API vô hạn.
Lưu ý: Trong ví dụ này còn một cách đơn giản hơn. Bạn tạo object bên trong effect và đặt các giá trị primitive vào dependency:
useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);. Hãy ưu tiên cách đó. Chỉ dùnguseMemokhi object buộc phải sống bên ngoài effect, ví dụ khi nó được truyền xuống component con.
4. useCallback: ghi nhớ một function
useCallback(fn, deps) tương đương với useMemo(() => fn, deps). Nó trả về chính function bạn truyền vào và giữ nguyên tham chiếu cho đến khi deps đổi. useCallback không làm function chạy nhanh hơn. Hook này chỉ có ý nghĩa khi function đó được so sánh ở đâu đó.
4.1. Kết hợp với React.memo
React.memo bỏ qua render của component con nếu props giống lần trước (so sánh nông). Nếu một prop là function được tạo mới mỗi lần, memo mất tác dụng.
import { memo, useCallback, useState } from 'react';
const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
onRender?.(order.id);
return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});
export function OrderList({ orders, onRender }) {
const [selectedId, setSelectedId] = useState(null);
const [note, setNote] = useState('');
const handleSelect = useCallback((id) => setSelectedId(id), []);
return (
<>
<input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
<ul>
{orders.map((o) => (
<OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
))}
</ul>
<p>Đang chọn: {selectedId ?? 'chưa có'}</p>
</>
);
}
Giải thích:
handleSelectcó dependency rỗng vìsetSelectedIdluôn ổn định (React đảm bảo điều này cho setter củauseState).- Khi bạn gõ vào ô ghi chú,
OrderListrender lại. Tuy vậy, mỗiOrderRownhận cùngorder, cùnghandleSelect, cùngonRender, nênmemobỏ qua chúng. - Nếu đổi
handleSelectthành arrow function thường, mọi dòng sẽ render lại mỗi lần gõ phím.
Điểm cần nhớ: useCallback không có memo ở phía nhận thì gần như vô dụng. Truyền một callback đã memo vào thẻ <button> của DOM không tiết kiệm được gì.
4.2. Function dùng làm dependency
function useOrderPolling(customerId) {
const [orders, setOrders] = useState([]);
const refresh = useCallback(async () => {
const data = await fetchOrders({ customerId });
setOrders(data);
}, [customerId]);
useEffect(() => {
refresh();
const id = setInterval(refresh, 10_000);
return () => clearInterval(id);
}, [refresh]);
return { orders, refresh };
}
refresh vừa được dùng trong effect vừa được trả ra cho component bên ngoài. Nhờ useCallback, interval chỉ được tạo lại khi customerId đổi. Component nào dùng refresh làm dependency cũng không bị chạy lại oan.
Đây là quy tắc đáng giữ khi viết custom hook: function trả ra ngoài nên được bọc useCallback. Bạn không biết trước người dùng hook sẽ đặt nó vào dependency hay truyền vào memo.
5. Kiểm chứng bằng test
Thay vì tin rằng memo “chắc là chạy”, bạn có thể viết test cho nó. Cài Vitest và React Testing Library:
npm install -D vitest jsdom @testing-library/react
Cấu hình môi trường jsdom trong vite.config.js:
export default {
test: { environment: 'jsdom' },
};
Test cho OrderList ở mục 4.1:
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';
const orders = [
{ id: 1, customer: 'Lan' },
{ id: 2, customer: 'Minh' },
];
describe('OrderList', () => {
it('không render lại OrderRow khi chỉ gõ ghi chú', () => {
const onRender = vi.fn();
render(<OrderList orders={orders} onRender={onRender} />);
expect(onRender).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('note'), { target: { value: 'giao gấp' } });
expect(onRender).toHaveBeenCalledTimes(2);
});
it('vẫn cập nhật khi chọn đơn hàng', () => {
render(<OrderList orders={orders} />);
fireEvent.click(screen.getByText('Minh'));
expect(screen.getByText('Đang chọn: 2')).toBeTruthy();
});
});
✓ OrderList > không render lại OrderRow khi chỉ gõ ghi chú
✓ OrderList > vẫn cập nhật khi chọn đơn hàng
Thử bỏ useCallback rồi chạy lại. Test đầu sẽ fail với expected 4 calls, received 2… đúng hơn là nhận 4 lần gọi thay vì 2. Đó là bằng chứng rõ ràng rằng hook đang làm việc của nó. Hãy khai báo onRender bên ngoài và truyền cùng một tham chiếu, nếu không chính prop này sẽ phá memo.
Ngoài test, bạn nên dùng tab Profiler của React DevTools với tùy chọn “Highlight updates” để thấy trực quan component nào render lại.
6. Khi nào KHÔNG nên dùng
Memo hóa không miễn phí. React phải lưu giá trị cũ, lưu mảng dependency và so sánh chúng mỗi lần render. Code cũng khó đọc hơn. Bạn nên bỏ qua trong các trường hợp sau:
- Phép tính rẻ: cộng vài số, nối chuỗi,
mapqua vài chục phần tử. - Giá trị primitive:
useMemo(() => a + b, [a, b])trả về số. Số đã được so sánh theo giá trị, nên memo không giúp gì. - Callback truyền vào phần tử DOM hoặc vào component con không bọc
memo. - Component con vẫn nhận prop thay đổi mỗi lần. Ví dụ
<OrderRow order={{ ...o }} />hoặc truyềnchildrenlà JSX. JSX cũng là object mới mỗi lần render, nênmemosẽ luôn so sánh thấy khác. - Có thể sửa cấu trúc thay vì memo. Đưa state xuống gần nơi dùng, hoặc truyền component nặng qua
children, thường loại bỏ render thừa triệt để hơn.
Lưu ý: Đừng dựa vào
useMemođể đảm bảo đúng logic. React có quyền xóa cache, ví dụ trong một số tình huống với Suspense hoặc trong tương lai. Code của bạn phải chạy đúng dù không có memo. Hook này chỉ là tối ưu hiệu năng. Tương tự, đừng đặt side effect như gọi API hay ghilocalStoragetronguseMemo. Chỗ của chúng làuseEffecthoặc event handler.
Lỗi hay gặp nhất là thiếu dependency, dẫn tới stale closure:
const handleSave = useCallback(() => {
saveOrder({ ...order, note });
}, [order]); // thiếu `note`
handleSave sẽ luôn dùng giá trị note của lần tạo đầu tiên. Hãy bật rule react-hooks/exhaustive-deps của eslint-plugin-react-hooks và đừng tắt cảnh báo của nó.
7. React Compiler thay đổi gì
React Compiler, đã ổn định từ cuối năm 2025, phân tích component lúc build và tự chèn memo hóa ở mức chi tiết hơn những gì bạn thường viết tay. Để thử trong dự án Vite hoặc Babel:
npm install -D babel-plugin-react-compiler
Khi compiler đã bật, bạn hầu như không cần viết useMemo, useCallback hay memo mới. Các hook cũ vẫn hoạt động. Chúng hữu ích khi bạn cần kiểm soát chính xác một dependency của effect. Compiler chỉ tối ưu được code tuân thủ các quy tắc của React: component thuần, không sửa trực tiếp props hoặc state. Vì vậy hiểu rõ hai hook trong bài vẫn cần thiết để đọc code cũ và gỡ lỗi khi compiler bỏ qua một component.
8. Kết luận
useMemo và useCallback giải quyết cùng một vấn đề: giữ tham chiếu ổn định giữa các lần render. Chúng thực sự đáng dùng trong ba tình huống:
- Phép tính đo được là tốn kém.
- Giá trị hoặc function được truyền vào component bọc
React.memo. - Giá trị hoặc function nằm trong dependency của
useEffecthay hook khác, bao gồm function trả ra từ custom hook.
Ngoài các trường hợp đó, hãy để code đơn giản. Đo bằng Profiler, chứng minh bằng test, và cân nhắc bật React Compiler để công cụ làm phần việc này thay bạn.