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 filter và sort chỉ chạy lại khi orders hoặc keyword đổi.
  • Khi bạn bấm “Đổi giao diện”, darkMode đổi và component render lại. Lúc này useMemo trả về mảng đã lưu, không lọc lại.
  • sort an 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 prop orders.

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ùng useMemo khi 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:

  • handleSelect có dependency rỗng vì setSelectedId luôn ổn định (React đảm bảo điều này cho setter của useState).
  • Khi bạn gõ vào ô ghi chú, OrderList render lại. Tuy vậy, mỗi OrderRow nhận cùng order, cùng handleSelect, cùng onRender, nên memo bỏ qua chúng.
  • Nếu đổi handleSelect thà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, map qua 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ền children là JSX. JSX cũng là object mới mỗi lần render, nên memo sẽ 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 ghi localStorage trong useMemo. Chỗ của chúng là useEffect hoặ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:

  1. Phép tính đo được là tốn kém.
  2. Giá trị hoặc function được truyền vào component bọc React.memo.
  3. Giá trị hoặc function nằm trong dependency của useEffect hay 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.

reacthooksusememousecallbackperformance