Bài 23.1: useMemo và useCallback trong TypeScript

Bài 23.1: useMemo và useCallback trong TypeScript
Chào mừng bạn quay trở lại với series blog về Lập trình Web Front-end! Hôm nay, chúng ta sẽ cùng nhau đi sâu vào hai hook cực kỳ mạnh mẽ và quan trọng trong React để tối ưu hóa hiệu suất: useMemo
và useCallback
. Nếu bạn đã làm việc với React một thời gian, chắc hẳn đã có lúc bạn gặp phải tình trạng ứng dụng chạy chậm, hoặc các component re-render một cách không cần thiết. Đây chính là lúc bộ đôi "siêu anh hùng" này phát huy tác dụng!
Tại sao lại cần tối ưu hóa? React rất thông minh, nó chỉ cập nhật DOM khi cần. Tuy nhiên, quá trình tính toán để xác định xem có cần cập nhật hay không (quá trình render) vẫn diễn ra. Trong các component functional, mỗi khi component re-render (do state hoặc props thay đổi), tất cả mọi thứ bên trong thân hàm component đó đều được thực thi lại. Điều này bao gồm cả việc tạo ra các hàm mới, các đối tượng mới, hoặc thực hiện các phép tính. Đối với các ứng dụng nhỏ, điều này thường không phải là vấn đề. Nhưng với các ứng dụng lớn, phức tạp, xử lý nhiều dữ liệu hoặc có cấu trúc component sâu, việc tạo lại các giá trị hoặc hàm "nặng" trên mỗi lần render có thể ngốn CPU và làm chậm đáng kể trải nghiệm người dùng.
useMemo
và useCallback
giúp chúng ta giải quyết vấn đề này bằng cách ghi nhớ (memoize) kết quả của phép tính hoặc định nghĩa hàm, chỉ tính toán lại hoặc tạo lại khi các dependencies (giá trị phụ thuộc) của chúng thay đổi.
useMemo
: Nhớ Kỹ Giá Trị Tính Toán
useMemo
là hook được sử dụng để ghi nhớ kết quả của một phép tính tốn kém. Thay vì thực hiện lại phép tính đó trên mỗi lần component re-render, useMemo
sẽ chỉ chạy lại phép tính khi các dependencies của nó thay đổi. Nếu dependencies không đổi, nó sẽ trả về kết quả đã được ghi nhớ từ lần render trước.
Cú pháp cơ bản:
import React, { useMemo } from 'react';
const memoizedValue = useMemo(() => {
// Đây là nơi thực hiện phép tính mà bạn muốn memoize
// Ví dụ: Lọc một mảng lớn, tính toán phức tạp, v.v.
console.log('Đang tính toán lại giá trị...');
return calculateExpensiveValue(dependency1, dependency2);
}, [dependency1, dependency2]); // Mảng các giá trị dependencies
- Tham số đầu tiên: Một hàm factory (hàm tạo) chứa logic tính toán. Hàm này sẽ được thực thi để lấy giá trị cần memoize.
- Tham số thứ hai: Một mảng các dependencies. Đây là chìa khóa!
useMemo
sẽ chỉ chạy lại hàm factory khi có ít nhất một giá trị trong mảng này thay đổi (so sánh bằng strict equality===
). Nếu mảng rỗng[]
, hàm factory sẽ chỉ chạy một lần duy nhất sau lần render đầu tiên.
Ví dụ minh họa:
Giả sử bạn có một danh sách sản phẩm rất lớn và cần hiển thị tổng giá của các sản phẩm đang hiển thị, hoặc lọc danh sách này theo một tiêu chí phức tạp.
import React, { useMemo, useState } from 'react';
interface Product {
id: number;
name: string;
price: number;
quantity: number;
}
// Giả lập một danh sách sản phẩm lớn
const allProducts: Product[] = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Sản phẩm ${i}`,
price: Math.random() * 100 + 10,
quantity: Math.floor(Math.random() * 10) + 1,
}));
function ProductList({ categoryFilter }: { categoryFilter: string }) {
const [searchTerm, setSearchTerm] = useState('');
// Sử dụng useMemo để chỉ tính toán lại danh sách sản phẩm đã lọc khi cần
const filteredProducts = useMemo(() => {
console.log('➡️ Đang thực hiện phép lọc sản phẩm...'); // Log này chỉ chạy khi dependencies thay đổi
return allProducts.filter(product => {
// Giả sử logic lọc phức tạp hơn ở đây...
return product.name.includes(searchTerm) /* && product.category === categoryFilter */;
});
}, [searchTerm, categoryFilter]); // Dependencies: searchTerm và categoryFilter
// Sử dụng useMemo để chỉ tính toán lại tổng giá khi danh sách lọc thay đổi
const totalValue = useMemo(() => {
console.log('💲 Đang tính toán lại tổng giá trị...'); // Log này chỉ chạy khi filteredProducts thay đổi
return filteredProducts.reduce((sum, product) => sum + product.price * product.quantity, 0);
}, [filteredProducts]); // Dependency: filteredProducts (là giá trị được memoize bởi useMemo khác)
return (
<div>
<h1>Danh sách Sản phẩm</h1>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Tìm kiếm theo tên..."
/>
{/* Thêm input cho categoryFilter nếu có */}
<p>
Tổng giá trị các sản phẩm hiển thị: **{totalValue.toFixed(2)}**
</p>
<ul>
{filteredProducts.map(product => (
<li key={product.id}>
{product.name} - {product.price.toFixed(2)} x {product.quantity}
</li>
))}
</ul>
</div>
);
}
Giải thích ví dụ useMemo
:
Trong ví dụ này, chúng ta có hai lần sử dụng useMemo
:
filteredProducts
: Hook này bọc logic lọc danh sáchallProducts
. Hàm lọc này sẽ chỉ chạy lại khisearchTerm
hoặccategoryFilter
thay đổi. Nếu componentProductList
re-render vì bất kỳ lý do nào khác (ví dụ: state nội bộ khác thay đổi mà không phảisearchTerm
), hàm lọc sẽ không chạy lại.useMemo
trả về mảngfilteredProducts
đã được ghi nhớ.totalValue
: Hook này tính tổng giá trị từ danh sáchfilteredProducts
. Nó phụ thuộc vàofilteredProducts
. Bất cứ khi nàofilteredProducts
thay đổi (dosearchTerm
hoặccategoryFilter
thay đổi và kích hoạtuseMemo
đầu tiên),useMemo
thứ hai sẽ chạy lại để tính toán lạitotalValue
. NếufilteredProducts
không thay đổi,totalValue
cũng sẽ giữ nguyên giá trị đã ghi nhớ.
Điều này đảm bảo rằng các phép tính tốn kém chỉ thực sự xảy ra khi dữ liệu đầu vào của chúng thay đổi, giúp ứng dụng phản ứng nhanh hơn, đặc biệt khi làm việc với các danh sách lớn hoặc các tính toán phức tạp khác.
useCallback
: Ghi Nhớ Định Nghĩa Hàm
Trong khi useMemo
ghi nhớ giá trị trả về từ một hàm, useCallback
lại ghi nhớ chính định nghĩa hàm. Nó trả về một phiên bản ghi nhớ (memoized) của hàm callback được truyền vào. Phiên bản này chỉ thay đổi khi một trong các dependencies của nó thay đổi.
Cú pháp cơ bản:
import React, { useCallback } from 'react';
const memoizedCallback = useCallback(() => {
// Logic của hàm callback
console.log('Hàm callback đang được gọi...');
doSomething(dependency1, dependency2);
}, [dependency1, dependency2]); // Mảng các giá trị dependencies
- Tham số đầu tiên: Hàm mà bạn muốn memoize.
- Tham số thứ hai: Mảng các dependencies.
useCallback
sẽ trả về một định nghĩa hàm mới khi và chỉ khi một trong các giá trị trong mảng này thay đổi.
Tại sao cần ghi nhớ định nghĩa hàm?
Như đã nói ở trên, trong JavaScript, mỗi khi một component functional re-render, tất cả các hàm được định nghĩa trực tiếp bên trong thân hàm component đó sẽ được tạo lại. Về mặt kỹ thuật, đây là một định nghĩa hàm mới trong bộ nhớ, ngay cả khi logic của hàm không đổi.
Điều này rất quan trọng khi bạn truyền hàm này xuống làm props cho một component con, đặc biệt là component con đó đã được tối ưu hóa bằng React.memo
. React.memo
là một HOC (Higher-Order Component) giúp ngăn component con re-render nếu các props của nó không thay đổi. Tuy nhiên, nếu một prop là hàm và hàm đó được tạo mới trên mỗi lần render của component cha (vì bạn không dùng useCallback
), thì React.memo
sẽ so sánh định nghĩa hàm cũ và mới, thấy chúng khác nhau (vì là hai đối tượng hàm khác nhau trong bộ nhớ), và quyết định rằng prop đã thay đổi. Kết quả là component con vẫn sẽ re-render, làm mất tác dụng của React.memo
.
useCallback
giải quyết vấn đề này bằng cách đảm bảo rằng prop hàm được truyền xuống component con được React.memo
bọc là cùng một định nghĩa hàm giữa các lần render của component cha (miễn là dependencies không đổi), nhờ đó React.memo
có thể hoạt động hiệu quả.
Ví dụ minh họa:
Chúng ta sẽ tạo một component button đơn giản và bọc nó bằng React.memo
để tối ưu hóa. Sau đó, truyền một hàm xử lý click xuống nó.
import React, { useState, useCallback, memo } from 'react';
// Component con được tối ưu hóa bằng React.memo
interface MemoizedButtonProps {
onClick: () => void;
label: string;
}
const MemoizedButton = memo(({ onClick, label }: MemoizedButtonProps) => {
// Log này chỉ nên xuất hiện khi component thực sự re-render (khi props thay đổi)
console.log(`➡️ Đang render component "${label}"...`);
return (
<button
onClick={onClick}
style={{ margin: '5px', padding: '10px', cursor: 'pointer' }}
>
{label}
</button>
);
});
function ParentComponent() {
const [count, setCount] = useState(0);
const [feedback, setFeedback] = useState('');
// Hàm xử lý TĂNG count - Được bọc bởi useCallback
const handleIncrement = useCallback(() => {
console.log('⚙️ Hàm handleIncrement được gọi');
setCount(prevCount => prevCount + 1); // Sử dụng updater function để không cần count trong dependencies
}, []); // Dependency array rỗng: Hàm này chỉ được tạo ra một lần duy nhất sau render đầu tiên
// Hàm xử lý gửi feedback - KHÔNG được bọc bởi useCallback (chỉ để so sánh)
const handleSubmitFeedback = () => {
console.log('⚙️ Hàm handleSubmitFeedback được gọi');
// Giả lập gửi feedback
alert(`Feedback đã gửi: Count hiện tại là ${count}`);
setFeedback(`Feedback gửi lúc count = ${count}`);
};
console.log('➡️ Đang render ParentComponent...');
return (
<div>
<h1>useCallback Demo</h1>
<p>Count: **{count}**</p>
<p>Feedback cuối cùng: *{feedback}*</p>
{/* Truyền hàm handleIncrement đã được memoize */}
<MemoizedButton onClick={handleIncrement} label="Tăng Count (useCallback)" />
{/*
Nếu bạn truyền handleSubmitFeedback cho MemoizedButton, nó sẽ khiến
MemoizedButton re-render mỗi khi handleSubmitFeedback được tạo lại.
handleSubmitFeedback được tạo lại mỗi khi ParentComponent re-render
(ví dụ: khi count thay đổi) vì nó không dùng useCallback.
*/}
<button
onClick={handleSubmitFeedback}
style={{ margin: '5px', padding: '10px', cursor: 'pointer' }}
>
Gửi Feedback (Không dùng useCallback)
</button>
</div>
);
}
Giải thích ví dụ useCallback
:
Trong ví dụ này:
- Component
MemoizedButton
được bọc bởiReact.memo
, có nghĩa là nó sẽ không re-render trừ khi props của nó (onClick
vàlabel
) thay đổi. - Hàm
handleIncrement
được bọc bởiuseCallback
với dependency array rỗng[]
. Điều này có nghĩa là React sẽ trả về cùng một định nghĩa hàmhandleIncrement
này trên mọi lần re-render củaParentComponent
. - Hàm
handleSubmitFeedback
không được bọc bởiuseCallback
. Do đó, mỗi khiParentComponent
re-render (ví dụ: khicount
thay đổi do click button "Tăng Count"),handleSubmitFeedback
sẽ được tạo lại như một định nghĩa hàm mới.
Khi bạn click vào nút "Tăng Count (useCallback)", state count
thay đổi, ParentComponent
re-render. handleIncrement
vẫn là định nghĩa hàm cũ (nhờ useCallback
), handleSubmitFeedback
là định nghĩa hàm mới. Nếu chúng ta truyền handleIncrement
cho MemoizedButton
, React.memo
thấy prop onClick
không thay đổi nên MemoizedButton
không re-render (log "Đang render component..." không xuất hiện). Tuy nhiên, nếu chúng ta truyền handleSubmitFeedback
cho một MemoizedButton
, thì React.memo
sẽ thấy prop onClick
là định nghĩa hàm mới, và component đó sẽ re-render.
Đây là lý do chính tại sao useCallback
lại quan trọng khi làm việc với React.memo
và các hooks có dependency array như useEffect
hoặc useMemo
khác, bởi vì nó giúp ổn định các dependencies là hàm.
Lưu ý về dependencies: Trong handleIncrement
, chúng ta sử dụng setCount(prevCount => prevCount + 1)
. Đây là cách sử dụng updater function của setState
. Nó nhận giá trị state trước đó làm đối số, giúp chúng ta tính toán state mới mà không cần phụ thuộc vào giá trị count
hiện tại từ scope bên ngoài của hàm callback. Do đó, chúng ta có thể dùng dependency array rỗng []
, làm cho hàm handleIncrement
ổn định hơn nhiều. Nếu bạn cần truy cập các state hoặc prop khác bên trong hàm callback, bạn cần thêm chúng vào dependency array của useCallback
. Luôn đảm bảo dependency array của bạn là chính xác!
useMemo
và useCallback
: Điểm Khác Biệt Cốt Lõi
Sau khi tìm hiểu về cả hai, ta thấy rõ điểm khác biệt chính:
useMemo
: Dùng để ghi nhớ kết quả của một hàm (một giá trị). Nó tránh việc tính toán lại giá trị đó trên mỗi lần render.useCallback
: Dùng để ghi nhớ định nghĩa của một hàm. Nó tránh việc tạo lại định nghĩa hàm đó trên mỗi lần render.
Một cách đơn giản để ghi nhớ: useMemo
trả về một giá trị, còn useCallback
trả về một hàm.
Thực tế, useCallback(fn, deps)
về cơ bản tương đương với useMemo(() => fn, deps)
. useCallback
chỉ là một cú pháp tiện lợi hơn và chuyên biệt cho trường hợp bạn muốn ghi nhớ một hàm.
Khi Nào Nên Sử Dụng (Và Khi Nào KHÔNG)
useMemo
và useCallback
là những công cụ tối ưu hóa mạnh mẽ, nhưng chúng không phải là viên đạn bạc và không nên lạm dụng. Việc sử dụng chúng cũng có chi phí nhất định (bộ nhớ để lưu trữ giá trị/hàm đã ghi nhớ, thời gian CPU để so sánh dependencies).
Bạn nên cân nhắc sử dụng khi:
- Thực hiện các phép tính tốn kém: Lọc/sắp xếp các mảng lớn, tính toán phức tạp, xử lý dữ liệu nặng... Nếu việc tính toán này diễn ra thường xuyên và làm chậm ứng dụng,
useMemo
là lựa chọn tốt. - Truyền callbacks xuống component con được memoize: Nếu bạn truyền một hàm xuống một component con được bọc bởi
React.memo
(hoặc sử dụng các kỹ thuật tối ưu hóa tương tự), hãy sử dụnguseCallback
cho hàm đó. Đây là trường hợp phổ biến và hiệu quả nhất củauseCallback
. - Dependencies của các hooks khác: Đôi khi, các dependencies trong
useEffect
,useMemo
,useCallback
khác lại là các đối tượng hoặc hàm được tạo mới trên mỗi lần render. Sử dụnguseMemo
hoặcuseCallback
cho các dependencies này có thể giúp ổn định chúng, ngăn chặn các hook phụ thuộc chạy lại không cần thiết.
Bạn không nên lạm dụng khi:
- Các phép tính đơn giản: Chi phí để thiết lập và so sánh dependencies của
useMemo
có thể còn lớn hơn chi phí thực hiện lại một phép tính đơn giản. Đừng bọcuseMemo
cho những thứ nhưa + b
hay'string'.toLowerCase()
. - Không có vấn đề về hiệu suất rõ ràng: Đừng tối ưu hóa quá sớm (premature optimization). Chỉ sử dụng
useMemo
hoặcuseCallback
khi bạn thực sự thấy hiệu suất bị ảnh hưởng (ví dụ: dùng React DevTools Profiler để đo lường) hoặc khi bạn biết chắc rằng việc tạo lại giá trị/hàm đó đang gây ra re-render không cần thiết cho các component con được memoize. - Làm code phức tạp hơn một cách không cần thiết: Đôi khi, việc thêm
useMemo
/useCallback
và quản lý dependency array có thể làm code khó đọc và hiểu hơn. Chỉ thêm chúng khi lợi ích về hiệu suất vượt trội so với chi phí về độ phức tạp.
Comments