Tối ưu hiệu năng React: đo bằng Profiler, giảm re-render, React Compiler và virtualization
Quy trình tối ưu một màn hình React chậm: đo bằng React DevTools Profiler và Performance tracks, hiểu vì sao component re-render, tách context, dùng memo đúng chỗ, bật React Compiler 1.0, virtualize danh sách với TanStack Virtual và đẩy tính toán nặng ra Web Worker.
Màn hình "Đơn hàng" trong dashboard admin có 5.000 dòng, một ô tìm kiếm, bộ lọc trạng thái và một drawer xem chi tiết. Gõ vào ô tìm kiếm thì chữ hiện chậm hơn ngón tay. Mở drawer thì cả trang khựng lại. Trên MacBook của dev mọi thứ "cũng tạm", nhưng nhân viên vận hành dùng laptop văn phòng thì than liên tục. Đây là dạng bài toán hiệu năng React điển hình: không phải tải trang chậm, mà mỗi lần state thay đổi, React phải làm quá nhiều việc.
Bài này đi theo đúng thứ tự mình xử lý màn hình đó. Đầu tiên là đo để biết component nào render, render vì sao và tốn bao lâu. Sau đó cắt bớt re-render bằng cách sắp xếp lại state và context, dùng memo đúng chỗ, và để React Compiler làm phần memo thủ công. Danh sách dài được xử lý bằng virtualization. Cuối cùng là tách bundle và đẩy tính toán nặng ra khỏi main thread. Các chỉ số tải trang và tương tác ở cấp toàn trang (LCP, INP, CLS), cách đo bằng dữ liệu người dùng thật và Long Animation Frames đã nằm trong bài Tối ưu Core Web Vitals. Ở đây mình chỉ nói về chi phí render của React.
TL;DR:
- Đo trên production build (
next build --profile) với CPU throttling, đừng đo ở dev mode. - Component re-render khi state của nó đổi, khi cha re-render, hoặc khi context nó dùng đổi. Props "thay đổi" không phải là nguyên nhân, cha re-render mới là nguyên nhân.
- Cách rẻ nhất để bớt re-render là đặt state gần nơi dùng và truyền
children. Rồi mới đếnmemo. - React Compiler 1.0 tự memo hoá component và hook, nhưng không sửa context fan-out, không virtualize danh sách và không làm phép tính nặng rẻ hơn.
- Danh sách hàng nghìn dòng thì virtualize. Tính toán chặn input thì dùng
useDeferredValuehoặc Web Worker.
Bước 1: đo trước khi sửa
React DevTools Profiler
Hai setting mình bật đầu tiên:
- Highlight updates when components render (tab Components, biểu tượng bánh răng, mục General): mỗi component vừa render sẽ được viền sáng. Gõ một ký tự vào ô tìm kiếm mà cả bảng 5.000 dòng nhấp nháy là bạn đã thấy vấn đề, chưa cần đọc số.
- Record why each component rendered while profiling (mục Profiler): sau khi record, chọn một component sẽ thấy lý do, ví dụ "Props changed: onToggle", "Hook 2 changed" hoặc "The parent component rendered".
Quy trình: bấm record, gõ ba ký tự, dừng. Flamegraph cho biết mỗi commit render những component nào, màu càng vàng thì càng tốn thời gian. Ranked chart sắp xếp component theo thời gian render. Mình tìm ba thứ: commit nào dài hơn 16ms, component nào render mà không cần, và lý do của chúng.
Chrome Performance panel và Performance tracks
Từ React 19.2, React ghi thêm các track riêng vào panel Performance của Chrome (ở bản dev và bản profiling): track Scheduler cho biết update thuộc priority nào (blocking, transition...) và track Components cho biết component nào render trong khoảng thời gian nào. Điểm mạnh của nó là bạn thấy công việc của React nằm cạnh mọi thứ khác trên main thread: script bên thứ ba, layout, paint. Nhớ bật CPU throttling (4x hoặc 6x) để mô phỏng máy của người dùng.
Đo trên production build
Dev mode chậm hơn production đáng kể. StrictMode render hai lần, và còn nhiều kiểm tra chỉ có ở dev. Tối ưu dựa trên số đo ở dev rất dễ đi sai hướng. Next.js cho phép build production có bật profiler của React:
# Production build có React profiling; --no-mangling giữ tên component dễ đọc
npx next build --profile --no-mangling
npx next start
Muốn đo có chủ đích một vùng UI (và gửi số về log), dùng component <Profiler>:
// app/(admin)/orders/orders-profiler.tsx
'use client'
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration) => {
// actualDuration: thời gian render cây con trong commit này (phần được memo bỏ qua không tính)
// baseDuration: ước lượng nếu render lại toàn bộ cây con không có memo
if (actualDuration > 16) {
console.warn(`[profiler] ${id} ${phase}: ${actualDuration.toFixed(1)}ms (base ${baseDuration.toFixed(1)}ms)`)
}
}
export function OrdersProfiler({ children }: { children: ReactNode }) {
return (
<Profiler id="OrdersTable" onRender={onRender}>
{children}
</Profiler>
)
}
So actualDuration với baseDuration cho biết memo đang tiết kiệm được bao nhiêu. Hai số gần bằng nhau nghĩa là mọi thứ đang render lại hết. Ở production build thường, <Profiler> không ghi gì cả, chỉ bản profiling mới có số.
Với người dùng thật, chỉ số cần theo dõi là INP. Bạn lấy nó bằng onINP từ web-vitals/attribution. Cách gắn đầy đủ và cách đọc attribution có trong bài Core Web Vitals. Khi INP của một trang có processingDuration cao, đó là lúc dùng các kỹ thuật dưới đây.
Vì sao component re-render
Một component render lại khi:
- State của chính nó thay đổi (
useState,useReducer). - Component cha render lại. Mặc định React render lại toàn bộ cây con, bất kể props có đổi hay không.
- Context mà nó đọc (
useContext/use) có value mới. - Một store bên ngoài mà nó subscribe (qua
useSyncExternalStore) báo thay đổi.
Điểm 2 là thứ hay bị hiểu sai nhất. "Props thay đổi" không kích hoạt render, chính việc cha render mới kích hoạt. memo chỉ là cách nói với React "nếu props giống hệt lần trước thì bỏ qua". Cũng nên nhớ render không đồng nghĩa với cập nhật DOM. Render là chạy hàm component và so sánh kết quả. Chi phí nằm ở số component phải chạy nhân với độ nặng của từng cái.
Đặt state gần nơi dùng
Phiên bản đầu của màn hình đơn hàng để mọi state ở cấp trang:
// Trước: gõ một ký tự => OrdersPage render => toàn bộ bảng và sidebar render lại
'use client'
import { useState } from 'react'
import type { Order } from '@/lib/orders'
import { OrdersTable } from './orders-table'
import { StatsSidebar } from './stats-sidebar'
export function OrdersPage({ orders }: { orders: Order[] }) {
const [draftQuery, setDraftQuery] = useState('') // chỉ ô input cần
const [appliedQuery, setAppliedQuery] = useState('')
return (
<div className="flex gap-6">
<div className="min-w-0 flex-1">
<form onSubmit={(e) => { e.preventDefault(); setAppliedQuery(draftQuery) }}>
<input value={draftQuery} onChange={(e) => setDraftQuery(e.target.value)} placeholder="Mã đơn, tên khách" />
</form>
<OrdersTable orders={orders} query={appliedQuery} />
</div>
<StatsSidebar orders={orders} />
</div>
)
}
draftQuery chỉ có ô input cần, nhưng vì nằm ở OrdersPage nên mỗi phím gõ render lại cả OrdersTable lẫn StatsSidebar. Sửa bằng cách đưa nó xuống một component riêng:
// Sau: state draft nằm trong SearchBox, cha chỉ nhận giá trị khi submit
'use client'
import { useState } from 'react'
import type { Order } from '@/lib/orders'
import { OrdersTable } from './orders-table'
import { StatsSidebar } from './stats-sidebar'
function SearchBox({ onSearch }: { onSearch: (query: string) => void }) {
const [draft, setDraft] = useState('')
return (
<form onSubmit={(e) => { e.preventDefault(); onSearch(draft.trim()) }}>
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Mã đơn, tên khách" />
</form>
)
}
export function OrdersPage({ orders }: { orders: Order[] }) {
const [query, setQuery] = useState('')
return (
<div className="flex gap-6">
<div className="min-w-0 flex-1">
<SearchBox onSearch={setQuery} />
<OrdersTable orders={orders} query={query} />
</div>
<StatsSidebar orders={orders} />
</div>
)
}
Giờ gõ phím chỉ render SearchBox, một component vài dòng. Không có memo nào, không thêm dependency nào. Đây là tối ưu mình luôn thử trước tiên.
Truyền children để cô lập cây con
Khi state bắt buộc phải nằm ở component bao ngoài (theo dõi scroll, kích thước, hover), hãy cho component đó nhận children thay vì tự render cây con:
'use client'
import { useEffect, useState, type ReactNode } from 'react'
export function StickyHeaderShell({ header, children }: { header: ReactNode; children: ReactNode }) {
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8)
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
return (
<>
<header className={scrolled ? 'shadow-md' : ''}>{header}</header>
{/* children là element do cha tạo ra: scrolled đổi không làm chúng render lại */}
<main>{children}</main>
</>
)
}
children là các React element đã được tạo ở component cha, nơi không có state scrolled. Khi StickyHeaderShell render lại, React thấy children vẫn là cùng một object như lần trước nên bỏ qua cả cây đó. Pattern này còn có ích trong Next.js: StickyHeaderShell là client component, nhưng children có thể là Server Component, nên phần lớn trang không phải gửi JS xuống client.
Context: tách nhỏ, selector và external store
Context là nguồn re-render diện rộng phổ biến nhất mình gặp. Mọi component đọc một context sẽ render lại khi value của nó đổi (so sánh bằng Object.is). Một AppContext chứa user, theme, giỏ hàng và thông báo nghĩa là thêm một sản phẩm vào giỏ làm render lại mọi chỗ đang đọc tên user.
Tách context theo tần suất thay đổi và tách actions khỏi data
// app/_providers/cart-provider.tsx
'use client'
import { createContext, use, useMemo, useState, type ReactNode } from 'react'
type CartItem = { productId: string; quantity: number }
type CartActions = {
add: (productId: string) => void
remove: (productId: string) => void
}
const CartItemsContext = createContext<CartItem[] | null>(null)
const CartActionsContext = createContext<CartActions | null>(null)
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([])
// actions không phụ thuộc items (dùng functional update) => object ổn định suốt vòng đời
const actions = useMemo<CartActions>(
() => ({
add: (productId) =>
setItems((prev) => {
const existing = prev.find((i) => i.productId === productId)
if (!existing) return [...prev, { productId, quantity: 1 }]
return prev.map((i) => (i.productId === productId ? { ...i, quantity: i.quantity + 1 } : i))
}),
remove: (productId) => setItems((prev) => prev.filter((i) => i.productId !== productId)),
}),
[],
)
// React 19: render context trực tiếp làm provider (React 18 dùng .Provider)
return (
<CartActionsContext value={actions}>
<CartItemsContext value={items}>{children}</CartItemsContext>
</CartActionsContext>
)
}
export function useCartItems() {
const items = use(CartItemsContext)
if (!items) throw new Error('useCartItems phải nằm trong <CartProvider>')
return items
}
export function useCartActions() {
const actions = use(CartActionsContext)
if (!actions) throw new Error('useCartActions phải nằm trong <CartProvider>')
return actions
}
Hai trăm nút "Thêm vào giỏ" trên trang listing chỉ gọi useCartActions(). Khi giỏ thay đổi, chỉ icon giỏ hàng (đọc useCartItems()) render lại. Trước khi tách, cả hai trăm nút đều render lại. Để ý {children} ở provider: CartProvider render lại khi items đổi, nhưng children vẫn là cùng một element, nên chỉ consumer mới render.
Khi cần selector: useSyncExternalStore
Context không có selector. Component đọc useCartItems() chỉ để hiện số lượng vẫn render lại khi quantity của một item đổi, dù tổng số không đổi. Khi gặp nhu cầu "chỉ render khi phần dữ liệu tôi quan tâm đổi", mình chuyển sang external store:
// lib/create-store.ts
import { useSyncExternalStore } from 'react'
export type Store<T> = {
getState: () => T
setState: (updater: (prev: T) => T) => void
subscribe: (listener: () => void) => () => void
}
export function createStore<T>(initial: T): Store<T> {
let state = initial
const listeners = new Set<() => void>()
return {
getState: () => state,
setState(updater) {
const next = updater(state)
if (Object.is(next, state)) return
state = next
listeners.forEach((listener) => listener())
},
subscribe(listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
}
}
// selector phải trả về primitive hoặc một tham chiếu đã có sẵn trong state
export function useStore<T, S>(store: Store<T>, selector: (state: T) => S): S {
const getSnapshot = () => selector(store.getState())
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
}
// Chỉ render lại khi tổng số lượng thay đổi
const cartStore = createStore<{ items: { productId: string; quantity: number }[] }>({ items: [] })
export function CartBadge() {
const count = useStore(cartStore, (s) => s.items.reduce((sum, i) => sum + i.quantity, 0))
return <span aria-label={`${count} sản phẩm trong giỏ`}>{count}</span>
}
React so sánh kết quả selector bằng Object.is, nên số lượng không đổi thì CartBadge không render. Hai cái bẫy:
- Selector trả về object hoặc array mới (
(s) => ({ count: ..., total: ... })) thì mỗi lần gọi đều là giá trị "mới". React sẽ cảnh báo snapshot cần được cache và có thể render lặp vô hạn. Hãy chọn từng primitive bằng các lần gọi riêng, hoặc dùng thư viện có so sánh shallow. - Store ở cấp module chạy cả trên server. Trong Next.js, module được chia sẻ giữa mọi request trên cùng một server process. Nếu bạn ghi dữ liệu theo user vào store module-level khi render trên server, user này có thể thấy dữ liệu của user khác. Với dữ liệu theo request, hãy tạo store trong một provider (mỗi request một instance) và truyền nó qua context.
Trên thực tế mình ít khi tự viết store. Zustand làm đúng việc này và có useShallow cho selector trả về object:
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
type CartState = {
items: { productId: string; quantity: number; price: number }[]
add: (productId: string, price: number) => void
}
export const useCartStore = create<CartState>()((set) => ({
items: [],
add: (productId, price) => set((s) => ({ items: [...s.items, { productId, quantity: 1, price }] })),
}))
export function CartSummary() {
// useShallow: chỉ render lại khi count hoặc total thực sự đổi
const { count, total } = useCartStore(
useShallow((s) => ({
count: s.items.length,
total: s.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
})),
)
return <p>{count} sản phẩm, {total.toLocaleString('vi-VN')}đ</p>
}
So sánh Zustand với Redux Toolkit và khi nào không cần thư viện nào, xem bài Zustand vs Redux Toolkit.
memo, useMemo, useCallback: khi nào giúp, khi nào là noise
memo chỉ có tác dụng khi hai điều kiện cùng đúng: component đó (hoặc cây con của nó) render tốn thời gian, và props của nó thực sự ổn định giữa các lần render của cha. Thiếu một trong hai, memo chỉ thêm một bước so sánh props.
Ví dụ đúng chỗ: chọn một dòng trong bảng 500 đơn hàng.
// app/(admin)/orders/orders-table.tsx
'use client'
import { memo, useCallback, useState } from 'react'
import type { Order } from '@/lib/orders'
type OrderRowProps = { order: Order; selected: boolean; onToggle: (id: string) => void }
const OrderRow = memo(function OrderRow({ order, selected, onToggle }: OrderRowProps) {
return (
<tr aria-selected={selected}>
<td>
<input type="checkbox" checked={selected} onChange={() => onToggle(order.id)} aria-label={`Chọn đơn ${order.code}`} />
</td>
<td>{order.code}</td>
<td>{order.customerName}</td>
<td>{order.total.toLocaleString('vi-VN')}đ</td>
</tr>
)
})
export function OrdersTable({ orders }: { orders: Order[] }) {
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(() => new Set())
// ổn định suốt vòng đời: không phụ thuộc selectedIds nhờ functional update
const toggle = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
return (
<table>
<tbody>
{orders.map((order) => (
<OrderRow key={order.id} order={order} selected={selectedIds.has(order.id)} onToggle={toggle} />
))}
</tbody>
</table>
)
}
Tick một checkbox: chỉ dòng đó có selected đổi, nên 1 dòng render thay vì 500. Ở đây useCallback là bắt buộc. Thiếu nó, toggle là function mới mỗi lần render và memo của cả 500 dòng đều vô hiệu.
Các trường hợp là noise:
// Noise: callback truyền cho phần tử DOM, không có component memo nào nhận nó
const handleOpen = useCallback(() => setOpen(true), [])
return <button onClick={handleOpen}>Mở</button>
// Noise: phép tính rẻ hơn cả chi phí useMemo
const fullName = useMemo(() => `${user.firstName} ${user.lastName}`, [user.firstName, user.lastName])
// Vô hiệu: memo nhưng nhận children (element mới mỗi lần render) hoặc object literal
<MemoCard style={{ padding: 16 }}>
<p>Doanh thu</p>
</MemoCard>
Quy tắc của mình: chỉ useMemo cho phép tính đo được trên 1ms (bọc bằng console.time khi đã bật CPU throttling), hoặc khi cần tham chiếu ổn định để đưa vào component có memo hay dependency của effect. Memo cũng có chi phí riêng: bộ nhớ giữ giá trị cũ, so sánh dependency mỗi lần render, và bug stale closure khi thiếu dependency.
React Compiler 1.0: để compiler làm phần memo
React Compiler ra bản 1.0 ổn định vào tháng 10/2025. Nó là một Babel plugin phân tích component và hook lúc build, rồi tự chèn memo hoá ở mức chi tiết: giá trị tính toán, JSX element, callback. Mọi thứ bạn làm bằng useMemo/useCallback/memo ở phần trước, compiler làm tự động và thường chi tiết hơn. Nó còn memo được cả giá trị nằm sau early return, chỗ bạn không thể gọi useMemo vì vi phạm rules of hooks.
Minh hoạ đã rút gọn rất nhiều (output thật dài và khó đọc hơn):
// Code bạn viết
function RevenueSummary({ orders, currency }: { orders: Order[]; currency: string }) {
const total = orders.reduce((sum, o) => sum + o.total, 0)
const formatted = new Intl.NumberFormat('vi-VN', { style: 'currency', currency }).format(total)
return <SummaryCard title="Tổng doanh thu" value={formatted} onExport={() => exportCsv(orders)} />
}
// Ý tưởng của output (minh hoạ)
import { c as _c } from 'react/compiler-runtime'
function RevenueSummary({ orders, currency }) {
const $ = _c(3)
let element
if ($[0] !== orders || $[1] !== currency) {
const total = orders.reduce((sum, o) => sum + o.total, 0)
const formatted = new Intl.NumberFormat('vi-VN', { style: 'currency', currency }).format(total)
element = <SummaryCard title="Tổng doanh thu" value={formatted} onExport={() => exportCsv(orders)} />
$[0] = orders
$[1] = currency
$[2] = element
} else {
element = $[2]
}
return element
}
Khi orders và currency không đổi, component trả lại đúng element cũ. React thấy element giống hệt nên bỏ qua cả SummaryCard, không cần bọc memo.
Bật trong Next.js và Vite
npm install -D babel-plugin-react-compiler
npm install -D eslint-plugin-react-hooks@latest
// next.config.ts (Next.js 16: option ở top-level; Next.js 15 nằm trong experimental.reactCompiler)
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactCompiler: true,
// Rollout dần: chỉ compile component/hook có directive 'use memo'
// reactCompiler: { compilationMode: 'annotation' },
}
export default nextConfig
Next.js dùng một bước lọc bằng SWC để chỉ chạy compiler (Babel) trên file có JSX hoặc hook, nên build chậm hơn một chút so với không bật nhưng không nhiều. Với Vite, cách cấu hình phụ thuộc phiên bản @vitejs/plugin-react:
// vite.config.ts với @vitejs/plugin-react 6 trở lên (option babel inline đã bị bỏ)
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
})
// Với @vitejs/plugin-react 5 trở xuống:
// plugins: [react({ babel: { plugins: ['babel-plugin-react-compiler'] } })]
Kiểm tra compiler đã chạy: trong React DevTools, component đã được tối ưu có badge "Memo ✨" cạnh tên. Phiên bản eslint-plugin-react-hooks mới có preset recommended (một số bản cũ hơn đặt ở recommended-latest) gồm các rule dựa trên compiler (purity, refs, immutability, set-state-in-render, incompatible-library...). Mình bật lint trước khi bật compiler vì rule nào báo lỗi thì component đó compiler sẽ bỏ qua.
Những gì compiler không làm, và ràng buộc của nó
- Code phải tuân theo Rules of React. Render phải pure, không mutate props/state, không đọc hay ghi
ref.currenttrong lúc render. Component vi phạm sẽ bị compiler lặng lẽ bỏ qua (không lỗi build), nên lint mới là nơi bạn thấy vấn đề. - Thư viện có interior mutability. Object giữ nguyên tham chiếu nhưng nội dung thay đổi sẽ phá memo. Docs của React nêu ví dụ
watch()của react-hook-form (dùnguseWatchthay thế) vàuseReactTablecủa TanStack Table. Ruleincompatible-librarycảnh báo các trường hợp đã biết. Khi một component chạy sai sau khi bật compiler, thêm'use no memo'ở đầu hàm để loại nó ra, rồi sửa nguyên nhân. - Không sửa context fan-out. Context đổi value thì mọi consumer vẫn render lại. Phần tách context ở trên vẫn cần.
- Không làm phép tính rẻ hơn. Compiler chỉ tránh tính lại khi input không đổi. Lọc 50.000 dòng khi
queryđổi vẫn tốn y như cũ. - Không virtualize, không code split. Render 5.000 dòng DOM vẫn là 5.000 dòng.
- React 17 và 18 được hỗ trợ nhưng cần cấu hình
targetvà package runtime riêng, xem trang compiler trên react.dev.
Còn các useMemo/useCallback có sẵn thì sao? Mình giữ nguyên. Compiler hoạt động cùng chúng được, và xoá hàng loạt khi chưa có test có thể làm effect chạy với tần suất khác trước.
Danh sách dài: key ổn định và virtualization
Key
// Sai: key theo index. Xoá đơn đầu tiên => dòng của đơn thứ 2 nhận lại state (ghi chú đang gõ dở) của đơn thứ 1
{orders.map((order, index) => <EditableOrderRow key={index} order={order} />)}
// Sai: key ngẫu nhiên => mỗi lần render unmount và mount lại toàn bộ dòng
{orders.map((order) => <EditableOrderRow key={crypto.randomUUID()} order={order} />)}
// Đúng: key là id ổn định từ dữ liệu
{orders.map((order) => <EditableOrderRow key={order.id} order={order} />)}
Key theo index chỉ an toàn khi danh sách không bao giờ được sắp xếp, lọc, chèn hay xoá, và các dòng không có state riêng. Bảng đơn hàng có sort và filter nên key bắt buộc là order.id.
Virtualization với TanStack Virtual
Sau khi đã memo, bảng 5.000 dòng vẫn chậm lúc mount và lúc filter, vì React phải tạo và trình duyệt phải layout 5.000 dòng DOM. Virtualization chỉ render những dòng đang nằm trong viewport, cộng một ít dư ra (overscan):
// app/(admin)/orders/virtual-order-list.tsx
'use client'
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { Order } from '@/lib/orders'
const ROW_HEIGHT = 48
export function VirtualOrderList({ orders }: { orders: Order[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: orders.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 8,
getItemKey: (index) => orders[index].id, // key ổn định khi sort/filter
})
return (
<div ref={scrollRef} className="h-[640px] overflow-auto rounded-lg border" role="list" aria-label="Danh sách đơn hàng">
{/* phần tử cao bằng toàn bộ danh sách để thanh cuộn đúng tỉ lệ */}
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const order = orders[item.index]
return (
<div
key={item.key}
role="listitem"
aria-setsize={orders.length}
aria-posinset={item.index + 1}
className="absolute left-0 top-0 flex w-full items-center gap-4 border-b px-4"
style={{ height: item.size, transform: `translateY(${item.start}px)` }}
>
<span className="w-28 shrink-0 font-mono">{order.code}</span>
<span className="min-w-0 flex-1 truncate">{order.customerName}</span>
<span className="w-32 shrink-0 text-right">{order.total.toLocaleString('vi-VN')}đ</span>
</div>
)
})}
</div>
</div>
)
}
Từ 5.000 dòng DOM xuống khoảng 30 dòng: 13 dòng nhìn thấy trong khung 640px, cộng overscan 8 dòng mỗi phía. Mount, filter và scroll đều nhẹ đi theo tỉ lệ đó. Với dòng có chiều cao thay đổi (ghi chú nhiều dòng), bỏ height cố định, gắn ref={virtualizer.measureElement} và data-index={item.index} lên mỗi dòng để thư viện đo kích thước thật.
Cái giá phải trả:
- Ctrl+F của trình duyệt không tìm thấy dòng chưa render. Bạn cần ô tìm kiếm riêng.
- Screen reader chỉ thấy các dòng đang render.
aria-setsize/aria-posinsetgiúp đọc đúng "dòng 37 trên 5000", nhưng trải nghiệm vẫn kém hơn bảng thật. Với bảng vài trăm dòng, mình không virtualize. - SSR không biết viewport. Server render danh sách rỗng hoặc vài dòng đầu, và nội dung đó không có trong HTML cho SEO. Với trang admin thì không sao, với trang listing public thì cân nhắc phân trang.
Có một lựa chọn rẻ hơn cho danh sách cỡ vừa (vài trăm đến một nghìn mục): CSS content-visibility: auto kèm contain-intrinsic-size: auto 48px. Trình duyệt bỏ qua layout và paint của phần ngoài màn hình, DOM vẫn đầy đủ nên Ctrl+F và screen reader vẫn chạy. Nhưng React vẫn phải render đủ mọi dòng, nên nó không giúp phần chi phí render.
Code splitting: lazy, Suspense, next/dynamic và phân tích bundle
JS ít hơn nghĩa là parse, compile và hydrate ít hơn. Ứng viên đầu tiên để tách là các component nặng mà không phải ai cũng dùng: rich text editor, chart, bản đồ, trình xem PDF.
// SPA với Vite: tách editor hoá đơn, tải trước khi người dùng hover nút
import { lazy, Suspense, useState } from 'react'
const InvoiceEditor = lazy(() => import('./invoice-editor')) // module phải có default export
const preloadEditor = () => void import('./invoice-editor') // cùng module => dùng chung cache
export function InvoiceActions({ invoiceId }: { invoiceId: string }) {
const [editing, setEditing] = useState(false)
return (
<>
<button type="button" onMouseEnter={preloadEditor} onFocus={preloadEditor} onClick={() => setEditing(true)}>
Sửa hoá đơn
</button>
{editing && (
<Suspense fallback={<div className="h-96 animate-pulse rounded-lg bg-gray-100" />}>
<InvoiceEditor invoiceId={invoiceId} />
</Suspense>
)}
</>
)
}
// Next.js: chart chỉ chạy được trên client (dùng window), tách chunk riêng
'use client'
import dynamic from 'next/dynamic'
const RevenueChart = dynamic(() => import('./revenue-chart'), {
ssr: false, // chỉ dùng được trong client component
loading: () => <div className="h-80 animate-pulse rounded-lg bg-gray-100" />,
})
export function RevenuePanel({ series }: { series: { date: string; total: number }[] }) {
return <RevenueChart series={series} />
}
Trong App Router, Server Component đã không gửi JS của chính nó xuống client, nên code splitting chủ yếu áp dụng cho client component. ssr: false gọi trong Server Component sẽ báo lỗi. Placeholder loading nên cao bằng component thật để không gây layout shift.
Muốn biết nên tách gì, hãy nhìn bundle trước:
# Next.js 16.1+ với Turbopack (mặc định): bundle analyzer thử nghiệm
npx next experimental-analyze
# Build bằng webpack: dùng @next/bundle-analyzer (không chạy với Turbopack)
ANALYZE=true npx next build --webpack
Thứ mình hay tìm thấy: thư viện date hoặc icon import cả gói qua barrel file, một thư viện chart nằm trong layout dùng chung, polyfill không cần thiết, và hai phiên bản của cùng một package. Với Vite, rollup-plugin-visualizer cho bạn treemap tương tự.
Tính toán nặng: useDeferredValue và Web Worker
useDeferredValue
Khi ô tìm kiếm điều khiển một danh sách tốn thời gian render, useDeferredValue cho React render ô input với giá trị mới trước, còn danh sách thì render lại sau với giá trị "chậm hơn". Lần render chậm đó có thể bị ngắt nếu người dùng gõ tiếp.
'use client'
import { memo, useDeferredValue, useMemo, useState } from 'react'
type Customer = { id: string; name: string; phone: string; city: string }
const CustomerResults = memo(function CustomerResults({ customers, query }: { customers: Customer[]; query: string }) {
const results = useMemo(() => {
const q = query.trim().toLowerCase()
return q ? customers.filter((c) => c.name.toLowerCase().includes(q) || c.phone.includes(q)) : customers
}, [customers, query])
return (
<ul>
{results.slice(0, 200).map((c) => (
<li key={c.id}>
{c.name} · {c.phone} · {c.city}
</li>
))}
</ul>
)
})
export function CustomerSearch({ customers }: { customers: Customer[] }) {
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const isStale = query !== deferredQuery
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Tên hoặc số điện thoại" />
<div style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
<CustomerResults customers={customers} query={deferredQuery} />
</div>
</>
)
}
Chi tiết quyết định mọi thứ: CustomerResults phải được memo (hoặc được React Compiler memo hoá). Nếu không, lần render khẩn cấp với query mới cũng render lại CustomerResults với deferredQuery cũ, tức làm đủ việc nặng ngay trong lần render bạn muốn nó nhanh.
useDeferredValue hay useTransition? Dùng useTransition khi bạn là người gọi setState và muốn đánh dấu chính lần cập nhật đó là không gấp. Dùng useDeferredValue khi bạn chỉ nhận giá trị (qua props hoặc từ hook khác) và không kiểm soát được chỗ set state.
Web Worker cho phép tính thật sự nặng
Cả hai API trên chỉ giúp render có thể bị ngắt giữa các component. Một vòng lặp tổng hợp 50.000 đơn hàng mất 400ms vẫn chặn main thread 400ms. Lúc đó hãy đưa nó ra khỏi main thread:
// app/(admin)/reports/aggregate.worker.ts
type OrderLite = { createdAt: string; customerId: string; total: number }
type AggregateRequest = { id: number; orders: OrderLite[]; groupBy: 'day' | 'customer' }
addEventListener('message', (event: MessageEvent<AggregateRequest>) => {
const { id, orders, groupBy } = event.data
const totals = new Map<string, number>()
for (const order of orders) {
const key = groupBy === 'day' ? order.createdAt.slice(0, 10) : order.customerId
totals.set(key, (totals.get(key) ?? 0) + order.total)
}
const rows = [...totals].sort((a, b) => b[1] - a[1])
postMessage({ id, rows })
})
// app/(admin)/reports/use-order-aggregates.ts
'use client'
import { useEffect, useRef, useState } from 'react'
type OrderLite = { createdAt: string; customerId: string; total: number }
type GroupBy = 'day' | 'customer'
type Row = [key: string, total: number]
type AggregateResponse = { id: number; rows: Row[] }
export function useOrderAggregates(orders: OrderLite[], groupBy: GroupBy) {
const workerRef = useRef<Worker | null>(null)
// request mới nhất; chỉ đọc/ghi trong effect và event handler, không đọc trong render
const latestRef = useRef<{ id: number; orders: OrderLite[]; groupBy: GroupBy } | null>(null)
const [result, setResult] = useState<{ rows: Row[]; orders: OrderLite[]; groupBy: GroupBy } | null>(null)
useEffect(() => {
const worker = new Worker(new URL('./aggregate.worker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (event: MessageEvent<AggregateResponse>) => {
const latest = latestRef.current
// bỏ qua response của request cũ khi người dùng đổi groupBy liên tục
if (!latest || event.data.id !== latest.id) return
setResult({ rows: event.data.rows, orders: latest.orders, groupBy: latest.groupBy })
}
workerRef.current = worker
return () => worker.terminate()
}, [])
useEffect(() => {
const id = (latestRef.current?.id ?? 0) + 1
latestRef.current = { id, orders, groupBy }
workerRef.current?.postMessage({ id, orders, groupBy })
}, [orders, groupBy])
// pending = kết quả đang hiển thị được tính cho input khác với input hiện tại
const isPending = !result || result.orders !== orders || result.groupBy !== groupBy
return { rows: result?.rows ?? null, isPending }
}
Cú pháp new Worker(new URL('./file.ts', import.meta.url), { type: 'module' }) được cả Next.js lẫn Vite nhận diện để bundle file worker riêng. Có hai cái giá. Dữ liệu gửi qua postMessage bị structured clone, và clone 50.000 object cũng tốn thời gian trên main thread. Nếu dữ liệu lớn và ít đổi, hãy gửi một lần rồi chỉ gửi tham số, hoặc cho worker tự fetch dữ liệu. Thứ hai, worker không truy cập được DOM hay state React, nên chỉ đẩy ra đó những phép tính thuần.
Bảng tổng hợp: triệu chứng, công cụ, cách sửa
| Triệu chứng | Công cụ xác nhận | Cách sửa |
|---|---|---|
| Gõ vào input bị trễ, cả trang nhấp nháy khi bật highlight | Highlight updates, Profiler "why did this render" | Đặt state gần nơi dùng, children pattern |
| Đổi một giá trị nhỏ (giỏ hàng, theme) làm render hàng trăm component | Profiler: "Context changed" | Tách context, tách actions khỏi data, external store có selector |
| Tick một dòng, cả bảng render lại | Profiler ranked chart | memo dòng + useCallback hoặc React Compiler |
| Đã memo nhưng vẫn render | "Props changed: onX / style" | Ổn định props, bỏ object literal, kiểm tra children |
| Mount/filter danh sách lớn chậm, DOM hàng nghìn node | Performance panel: Layout dài, Elements đếm node | TanStack Virtual, content-visibility: auto |
| Một component render rất lâu dù chỉ render một lần | Performance tracks: một block dài trong track Components | Giảm phép tính, useDeferredValue, Web Worker |
| Click lần đầu chậm khi trang vừa tải | INP attribution loadState: loading |
next/dynamic, lazy, giảm client component |
| Bundle lớn bất thường | next experimental-analyze, bundle analyzer |
Tách chunk, sửa import barrel, bỏ dependency trùng |
| Bật compiler rồi một form không cập nhật | Lint incompatible-library |
Đổi API (useWatch), 'use no memo' tạm thời |
Thứ tự áp dụng và khi nào dừng
Mình áp dụng theo thứ tự chi phí tăng dần, và đo lại sau mỗi bước:
- Sắp xếp lại state và
children. Không thêm dependency, không thêm khái niệm mới, thường giải quyết được phần lớn re-render thừa. - Tách context. Làm khi Profiler báo "Context changed" ở những component không liên quan.
- React Compiler (hoặc
memothủ công nếu chưa bật được). Làm khi các component nặng render lại với props giống hệt. - Virtualization. Làm khi số dòng DOM lên đến hàng nghìn, và khi việc mount hay filter vẫn chậm dù đã hết re-render thừa.
useDeferredValue/ Web Worker. Làm khi còn lại là chi phí tính toán thật, không phải render thừa.- Code splitting. Song song với các bước trên, khi bundle analyzer chỉ ra chunk lớn bất thường.
Điểm dừng của mình: với CPU throttling 4x, mỗi commit do tương tác gây ra dưới khoảng 16ms (một frame ở 60Hz) và INP p75 của trang trong RUM dưới 200ms. Quá điểm đó, mỗi giờ tối ưu thêm thường mang lại ít hơn một giờ làm tính năng. Code nhiều memo hơn cũng khó đọc hơn, nên dừng đúng lúc cũng là một quyết định kỹ thuật.
Những lỗi thường gặp
1. Đo và tối ưu ở dev mode. StrictMode render hai lần, bản dev có thêm kiểm tra. Một component "mất 30ms" ở dev có thể chỉ mất 8ms ở production. Dùng next build --profile.
2. Định nghĩa component bên trong component.
// Sai: OrderBadge là một component MỚI mỗi lần OrdersPage render => unmount/mount, mất state
function OrdersPage({ orders }: { orders: Order[] }) {
function OrderBadge({ status }: { status: string }) {
return <span className="badge">{status}</span>
}
return orders.map((o) => <OrderBadge key={o.id} status={o.status} />)
}
// Đúng: khai báo ở top-level của module
function OrderBadge({ status }: { status: string }) {
return <span className="badge">{status}</span>
}
3. Derive state bằng useEffect.
// Sai: render 2 lần (lần đầu với filtered cũ), thêm một state phải đồng bộ
const [filtered, setFiltered] = useState<Order[]>([])
useEffect(() => {
setFiltered(orders.filter((o) => o.status === status))
}, [orders, status])
// Đúng: tính trong lúc render (thêm useMemo nếu đo thấy nặng)
const filtered = orders.filter((o) => o.status === status)
4. Context value là object literal. <AuthContext value={{ user, logout }}> tạo object mới mỗi lần provider render, nên mọi consumer render theo. Bọc bằng useMemo hoặc tách context.
5. useDeferredValue mà component con không được memo. Bạn sẽ không thấy cải thiện nào, như đã giải thích ở trên.
6. memo cho component nhận children. JSX con là element mới ở mỗi lần render của cha, nên so sánh props luôn khác.
7. Bật React Compiler và xoá hết useMemo/useCallback trong cùng một PR. Tách thành hai bước, chạy test và đo lại giữa hai bước.
8. Virtualize mọi danh sách. Danh sách 50 đến 200 dòng thường không cần. Bạn mất Ctrl+F, thêm độ phức tạp và gặp vấn đề accessibility mà không được gì.
Checklist
- Có số đo trước và sau từ Profiler trên production build có profiling, với CPU throttling 4x.
- Đã bật "Highlight updates" và "Record why each component rendered" khi tái hiện bug.
- State chỉ một component con cần đã được đưa xuống component đó; component bao ngoài có state tần suất cao nhận
children. - Context lớn đã được tách theo tần suất thay đổi; actions tách khỏi data; value được memo.
-
memochỉ nằm ở component đo được là nặng và có props ổn định; không cònuseCallbacktruyền thẳng vào phần tử DOM. - Đã bật
eslint-plugin-react-hooksbản mới, sửa các lỗi rule compiler, rồi mới bậtreactCompiler(có thể bắt đầu bằngcompilationMode: 'annotation'). - Danh sách trên khoảng một nghìn dòng dùng TanStack Virtual với
getItemKeyổn định. - Đã chạy bundle analyzer và tách các component nặng bằng
lazyhoặcnext/dynamic. - Phép tính trên 50ms mỗi lần chạy nằm trong Web Worker hoặc được chia nhỏ.