React Performance: Đừng đoán mò, hãy đo rồi mới optimize
Profiling thực tế, React Compiler, code splitting, virtualization tất cả những gì bạn cần để tối ưu React app mà không cần đoán mò.
Nguyễn Nhật Long
@nguyennhatlong1303
Mình đã từng ngồi wrap React.memo() vào mọi component trong project vì nghĩ "chắc sẽ nhanh hơn". Kết quả? App không nhanh hơn mấy, nhưng code thì phức tạp hơn hẳn, và mỗi lần review PR đồng nghiệp đều hỏi "cái này memo để làm gì vậy?". Bài học rút ra: đừng optimize khi chưa có data.
Bài này mình sẽ đi qua toàn bộ workflow tối ưu React app theo đúng thứ tự từ đo lường, đến các công cụ hiện đại, đến những kỹ thuật cụ thể cho từng vấn đề.
Bắt đầu từ React DevTools Profiler
Trước khi làm bất cứ điều gì, mở React DevTools lên, vào tab Profiler, bật record rồi tương tác với app. Đây là bước mà nhiều người hay bỏ qua vì nghĩ mình đã biết vấn đề ở đâu rồi nhưng thực tế thường bất ngờ lắm.
Sau khi stop recording, bạn sẽ thấy flamegraph. Cái này cho bạn biết:
- Component nào render trong mỗi interaction
- Mỗi component mất bao lâu để render
- Tại sao nó render (prop thay đổi? state thay đổi? parent re-render?)
Một trick nhỏ mình hay dùng: vào Settings → Highlight updates when components render. Khi bật cái này, mỗi lần component re-render sẽ có highlight màu xanh/vàng/đỏ tùy theo tần suất. Nếu bạn thấy một component nháy liên tục khi bạn chỉ làm một thao tác nhỏ ở chỗ khác đó chính là chỗ cần xem xét.

Setting "Why did this render?" trong Profiler cũng cực kỳ hữu ích nó sẽ nói thẳng ra "component này render vì prop X thay đổi" hay "vì parent re-render". Không cần đoán nữa.
React Compiler Game changer của React 19
Nếu bạn đang dùng React 19, có một tin rất vui: bạn có thể quên đi phần lớn việc tự tay memo hóa.
React Compiler (trước đây gọi là React Forget) sẽ tự động phân tích code và thêm memoization ở những chỗ cần thiết. Tức là React.memo(), useMemo(), useCallback() compiler tự lo, bạn chỉ cần viết code logic bình thường.
Với Next.js, enable nó chỉ cần một dòng trong next.config.ts:
1// next.config.ts2import type { NextConfig } from 'next'34const nextConfig: NextConfig = {5 experimental: {6 reactCompiler: true,7 },8}910export default nextConfig
Mình thấy cái này hay ở chỗ: thay vì developer phải tự quyết định "chỗ này có cần memo không?", compiler phân tích data flow và tự đưa ra quyết định chính xác hơn. Ít bug hơn, code sạch hơn, không còn những cuộc tranh luận "có nên wrap cái này không" trong code review.
Tuy nhiên, nếu bạn đang dùng React 18 trở xuống, hoặc project chưa migrate lên được, thì vẫn cần hiểu các kỹ thuật manual.
Manual Memoization Dùng khi nào cho đúng
Đây là bảng tóm tắt nhanh để bạn biết dùng cái nào:
Quy tắc vàng mình luôn nhớ: chỉ dùng khi profiler đã cho thấy vấn đề. Không phải "component này trông có vẻ nặng" hay "mình cảm giác nó render nhiều" phải có số liệu thực tế.
| Hook/API | Dùng để làm gì | Khi nào thực sự cần |
|---|---|---|
| `React.memo()` | Skip re-render của component nếu props không đổi | Component render nặng, được gọi từ parent re-render thường xuyên |
| `useMemo()` | Cache kết quả của một phép tính | Computation tốn kém (sort/filter list lớn, transform data phức tạp) |
| `useCallback()` | Giữ stable reference cho function | Truyền callback xuống child component đã được `React.memo()` wrap |
Ví dụ điển hình:
1// Không cần thiết overhead của memo còn cao hơn cost của re-render2const SimpleLabel = React.memo(({ text }: { text: string }) => (3 <span>{text}</span>4))56// Hợp lý component phức tạp, render tốn kém7const DataTable = React.memo(({ rows, columns }: TableProps) => {8 // render 500 rows với complex formatting9 return (10 <table>11 {rows.map(row => (12 <ComplexRow key={row.id} data={row} columns={columns} />13 ))}14 </table>15 )16})
Code Splitting Đừng load những gì chưa cần
Một trong những quick win lớn nhất cho performance là giảm initial bundle size. Với React.lazy() và Suspense, bạn có thể defer việc load những component nặng cho đến khi thực sự cần:
1import { lazy, Suspense } from 'react'23// Component này chỉ được load khi user navigate đến trang cần nó4const HeavyChartComponent = lazy(() => import('./HeavyChartComponent'))5const RichTextEditor = lazy(() => import('./RichTextEditor'))67function Dashboard() {8 return (9 <div>10 <Suspense fallback={<ChartSkeleton />}>11 <HeavyChartComponent data={chartData} />12 </Suspense>1314 <Suspense fallback={<EditorSkeleton />}>15 <RichTextEditor />16 </Suspense>17 </div>18 )19}
Nếu dùng Next.js thì còn đơn giản hơn framework tự động code split theo route. Mỗi page là một chunk riêng, user vào trang nào thì load chunk đó. Bạn chỉ cần để ý đến những component đặc biệt nặng trong cùng một page (như rich text editor, chart library, map component) và lazy load chúng.
Anh em lưu ý: fallback UI quan trọng lắm. Đừng để fallback={<div>Loading...</div>} hãy dùng skeleton có shape giống component thật. UX sẽ mượt hơn nhiều và user ít cảm giác "giật" hơn.
Virtualization cho List Dài
Bài toán kinh điển: PM yêu cầu hiển thị danh sách 10,000 sản phẩm. Bạn render hết vào DOM browser freeze, user khổ, bạn cũng khổ.
Giải pháp là list virtualization: chỉ render những item đang visible trong viewport, còn lại thì không tồn tại trong DOM.
1import { useVirtualizer } from '@tanstack/react-virtual'2import { useRef } from 'react'34function VirtualList({ items }: { items: Product[] }) {5 const parentRef = useRef<HTMLDivElement>(null)67 const virtualizer = useVirtualizer({8 count: items.length,9 getScrollElement: () => parentRef.current,10 estimateSize: () => 72, // estimated row height in px11 overscan: 5, // render thêm 5 items ngoài viewport để scroll mượt12 })1314 return (15 <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>16 <div style={{ height: virtualizer.getTotalSize() }}>17 {virtualizer.getVirtualItems().map(virtualItem => (18 <div19 key={virtualItem.key}20 style={{21 position: 'absolute',22 top: 0,23 transform: `translateY(${virtualItem.start}px)`,24 width: '100%',25 }}26 >27 <ProductRow product={items[virtualItem.index]} />28 </div>29 ))}30 </div>31 </div>32 )33}
Mình prefer @tanstack/react-virtual hơn react-window vì nó flexible hơn handle được dynamic item height, horizontal list, grid. react-window thì API đơn giản hơn nhưng chỉ phù hợp với fixed-size items.
Khi nào thực sự cần virtualize? Theo kinh nghiệm của mình, khi list có hơn 100 items với rendering phức tạp (nhiều text, ảnh, nested components). Với list đơn giản 50-100 items thì thường không cần, overhead của virtualization không đáng.
Key Prop và Automatic Batching
Hai điểm nhỏ nhưng hay bị bỏ qua:
Key prop trong list key={index} là anti-pattern phổ biến nhất mình thấy trong code review:
1// ❌ Dùng index React sẽ re-mount components khi list thay đổi thứ tự2{items.map((item, index) => (3 <ProductCard key={index} product={item} />4))}56// ✅ Dùng unique ID React track đúng element, chỉ update khi cần7{items.map(item => (8 <ProductCard key={item.id} product={item} />9))}
Khi bạn dùng key={index} và list bị thêm/xóa/reorder, React sẽ nhầm lẫn về element nào là element nào, dẫn đến re-mount không cần thiết hoặc tệ hơn, state của component bị gán nhầm sang item khác.
Automatic Batching là feature từ React 18 mà nhiều người không để ý. Trước đây, multiple setState trong async code (setTimeout, fetch callback) sẽ trigger nhiều re-render riêng biệt. Từ React 18, React tự gom chúng lại thành 1 re-render:
1// React 18+: chỉ 1 re-render duy nhất, dù gọi setState 3 lần2fetch('/api/data').then(data => {3 setLoading(false)4 setData(data)5 setError(null)6 // → 1 re-render7})
Bạn không cần làm gì cả nó tự hoạt động. Chỉ cần biết để không bị bất ngờ khi debug.
Nhìn lại thì workflow tối ưu React khá rõ ràng: đo trước bằng Profiler, xác định vấn đề cụ thể, rồi mới áp dụng đúng tool. Nếu đang dùng React 19, React Compiler sẽ lo phần lớn memoization cho bạn. Với list dài thì virtualize. Với bundle nặng thì code split. Không có magic bullet nào fit tất cả mọi case nhưng có data trong tay thì quyết định sẽ dễ hơn nhiều.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!