Kỹ Thuật Chuyên Sâu • 10 phút đọc • 05/10/2026

React Reconciliation Thực Chiến: Key, Identity Và Cái Giá Thật Của Re-render

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Thiết kế giao diện trên màn hình
Ảnh: PDrouin (WMF) (text and layout) CMadeo (WMF) (included illustration) · CC BY-SA 4.0

Hai loại bug khác nhau nhưng cùng một gốc: xoá dòng đầu danh sách thì checkbox đang tick nằm lại ở dòng thứ hai; một ô input trong bảng 2,000 dòng làm cả trang đơ theo từng ký tự. Cả hai đều cùng một gốc: React không nhận ra hai thứ trước và sau là cùng một thứ.

1. Reconciliation: bài toán diff mà React từ chối giải

React không mutate DOM trực tiếp: mỗi lần state đổi, component trả về cây element mới, và React phải tìm ra cây cũ và cây mới khác nhau ở đâu.

So khớp hai cây bất kỳ là bài toán tree edit distance, chi phí O(n³): trang 1,000 node tốn cỡ một tỉ phép so sánh cho mỗi ký tự gõ vào. React bỏ tính tổng quát vì UI thật chỉ đổi cục bộ, và dùng hai heuristic.

Một: so sánh theo vị trí và type. Cùng vị trí, cùng type là cùng một thứ: cập nhật props, giữ DOM node và state. Khác type thì React unmount cả subtree rồi mount cái mới. DOM node tạo lại, state reset, effect cũ cleanup rồi effect mới chạy.

Hai: key là lối thoát cho danh sách. Trong một mảng element, vị trí không còn nói lên identity nên bạn khai báo nó. Diff danh sách thành so khớp có nhãn, vẫn O(n).

Biến thể của lỗi khác-type mà mình thấy nhiều nhất là component định nghĩa bên trong component:

// Mỗi lần Parent render, biến `Row` trỏ tới một function MỚI.
// Với reconciler đó là một type khác → unmount rồi mount lại toàn bộ <li>.
// Hệ quả: input mất focus sau mỗi ký tự, state trong Row bị reset.
function Parent({ items }: { items: Item[] }) {
  const Row = ({ item }: { item: Item }) => <li>{item.label}</li>;
  return (
    <ul>
      {items.map((item) => (
        <Row key={item.id} item={item} />
      ))}
    </ul>
  );
}

// Đúng: định nghĩa ở cấp module, type không đổi giữa các lần render.
function Row({ item }: { item: Item }) {
  return <li>{item.label}</li>;
}

function Parent({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item) => (
        <Row key={item.id} item={item} />
      ))}
    </ul>
  );
}

Reconciliation là heuristic. Cùng vị trí mà khác type thì React vứt sạch state của subtree cũ.

2. key là identity, không phải thứ để hết warning

Phần lớn người mới gặp key qua một dòng warning và phản xạ là thêm key={index} cho im — phản xạ đó sai, vì chỉ số mảng không phải identity mà là vị trí, thứ đổi khi danh sách đổi.

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          <input type="checkbox" defaultChecked={todo.done} />
          <span>{todo.title}</span>
        </li>
      ))}
    </ul>
  );
}

Với [A, B, C] xoá A còn [B, C]: React so theo vị trí, thấy vị trí 0 cùng type li và cùng key 0, nên coi là cùng một dòng và chỉ cập nhật text; DOM node không bị xoá.

Bug sinh ra ở đây. Trạng thái checkbox không nằm trong React, nó nằm trong DOM node. Với checkbox người dùng đã tương tác, checked không còn tuân theo attribute. Node cũ được tái sử dụng, nhưng cái tick ở lại trên dòng B. Component state cũng vậy: dòng đang mở để sửa bỗng thành dòng khác. Sửa bằng identity nghiệp vụ:

{todos.map((todo) => (
  <li key={todo.id}>
    <input type="checkbox" defaultChecked={todo.done} />
    <span>{todo.title}</span>
  </li>
))}

Ba tiêu chí cho key tốt: ổn định qua các lần render, duy nhất trong tập anh em, sinh từ dữ liệu. JSON.stringify(todo) vi phạm tiêu chí cuối — sửa title là key đổi, dòng mount lại, input đang gõ bị reset.

key={Math.random()} trong render khiến mọi key đổi ở mọi lần render: cả danh sách bị tháo ra dựng lại, state và focus mất, DOM churn — tệ nhất là nó vẫn “chạy” nên bug nằm lại rất lâu.

key là tín hiệu cho reconciler, không phải prop component đọc được: cần id trong row thì truyền thêm prop, và <></> không nhận key nên phải dùng <Fragment key={...}>.

3. Fiber: render phase và commit phase không giống nhau

Quy tắc “đừng gây side effect trong thân component” có lý do: React chia công việc thành hai pha khác hẳn nhau.

flowchart TB
  S["setState / props mới"] --> R["Render phase<br/>reconcile theo vị trí + type + key"]
  R --> K{"key khớp?"}
  K -- "khớp" --> U["Reuse fiber<br/>giữ nguyên state"]
  K -- "khác / index key đổi chỗ" --> M["Unmount + mount lại<br/>state và DOM reset"]
  U --> C["Commit phase<br/>mutate DOM, chạy effect"]
  M --> C

Đơn vị công việc là fiber: node trong cây liên kết (child / sibling / return) mang type, props, state và cờ đánh dấu việc cần làm. Cấu trúc đó cho phép React dừng giữa chừng, nhường main thread rồi quay lại.

Render phase chạy component function, so kết quả với cây cũ rồi ghi lại thay đổi. Pha này có thể bị ngắt, chạy lại hoặc vứt bỏ hoàn toàn — update ưu tiên thấp đang render dở có thể bị update ưu tiên cao hơn chèn ngang. Ở dev, StrictMode gọi component function hai lần để lộ component không tinh khiết.

Commit phase thì đồng bộ, không ngắt được: mutate DOM, chạy useLayoutEffect trước khi browser paint, nhường cho browser paint, rồi useEffect sau paint.

Hệ quả: thân component phải pure. Không fetch, không ghi log, không sinh id ngẫu nhiên, không mutate props hay state. Một lần render có thể chạy hai lần hoặc bị vứt đi. Side effect thì đã xảy ra rồi.

function Chart({ series }: { series: Series }) {
  // Sai: render có thể chạy hai lần (StrictMode) hoặc bị discard ở
  // concurrent render → request rác, số liệu analytics đếm sai.
  void fetch("/api/track", { method: "POST", body: String(series.id) });

  // Sai tiếp: id sinh trong render không ổn định giữa các lần render.
  const domId = `chart-${Math.random().toString(36).slice(2)}`;

  // Đúng: render chỉ tính toán từ props và state.
  const peak = Math.max(...series.points.map((p) => p.value));
  return <svg id={domId}>{/* ... */}</svg>;
}

4. Đo trước, và đo đúng thứ

Highlight re-render trong React DevTools cho biết vùng nào render sau một tương tác. Nó là công cụ định vị, không phải để kết luận: một component render 200 lần mà thân nó chỉ trả về ba div thì không đáng quan tâm.

Profiler trong DevTools cho self time (trong chính component) và total time (kể cả cây con). Self time nhỏ mà total time lớn nghĩa là component đang kéo cả cây theo. Bật “Record why each component rendered while profiling”: render vì state của chính nó thì memo không cứu được, render vì cha thì có thể.

<Profiler> API cho số liệu tự động, dùng được cả trên production:

import { Profiler, type ProfilerOnRenderCallback } from "react";

const onRender: ProfilerOnRenderCallback = (
  id,             // id của Profiler
  phase,          // "mount" | "update" | "nested-update"
  actualDuration, // thời gian React thực sự dùng cho commit này
  baseDuration,   // ước lượng thời gian render nếu KHÔNG có memo nào
) => {
  metrics.observe("react.render.ms", actualDuration, { id, phase });
};

export function Table({ rows }: { rows: Row[] }) {
  return (
    <Profiler id="Table" onRender={onRender}>
      <RowList rows={rows} />
    </Profiler>
  );
}

baseDuration là ước lượng thời gian render nếu không có memoization; actualDuration thấp hơn rõ rệt nghĩa là memo đang làm việc, hai số gần bằng nhau nghĩa là công memoize vô ích.

Giới hạn: Profiler chỉ đo render của React, không thấy layout hay paint — component render 0.5ms nhưng chèn 5,000 DOM node sẽ vô hình trên flame chart.

5. Vì sao context kéo theo mọi consumer

Context là publish/subscribe, và không có selector. Khi value của provider đổi — so bằng Object.is trên chính value đó — React đánh dấu mọi consumer là cần update. Nó không kiểm tra consumer có thật sự dùng phần dữ liệu vừa đổi.

Bẫy thứ nhất: value {{ user, login }} viết trực tiếp trong JSX tạo reference mới ở mỗi lần provider render, kể cả khi dữ liệu không đổi.

function CartProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<Item[]>([]);

  // Reference ổn định: callback không được tạo lại mỗi render.
  const add = useCallback((item: Item) => {
    setItems((prev) => [...prev, item]);
  }, []);

  // Value chỉ đổi khi dữ liệu bên trong đổi.
  const value = useMemo(() => ({ items, add }), [items, add]);

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}

Bẫy thứ hai: React.memo không chặn được re-render do context. Memo so props, còn subscription vào context là đường riêng. Consumer vẫn render dù props y nguyên.

Cách chữa là làm độ hạt của cái được subscribe khớp với cái thay đổi: tách context theo tần suất (theme gần như không đổi, giỏ hàng đổi mỗi lần bấm), hạ provider xuống thấp hơn, hoặc dùng store có selector.

Hướng thứ ba dùng useSyncExternalStore: mỗi subscriber khai báo hàm chọn dữ liệu, React lấy snapshot rồi so bằng Object.is, chỉ re-render khi kết quả chọn đổi.

import { create } from "zustand";

type CartState = { items: CartItem[]; add: (item: CartItem) => void };

export const useCart = create<CartState>()((set) => ({
  items: [],
  add: (item) => set((state) => ({ items: [...state.items, item] })),
}));

// Selector trả về số nguyên → Object.is so sánh đúng, và component
// chỉ re-render khi con số của chính row này đổi.
export function RowQty({ id }: { id: string }) {
  const qty = useCart((s) => s.items.find((i) => i.id === id)?.qty ?? 0);
  return <span>{qty}</span>;
}

Cái giá: selector chạy cho mọi subscriber ở mọi lần store đổi. find trên mảng 5,000 phần tử nhân 1,000 subscriber là bẫy mới thay bẫy cũ. Chuẩn hoá thành map theo id.

6. memo, useMemo, useCallback và gốc rễ là referential identity

Ba API này giải đúng một vấn đề: JS so sánh object, array và function bằng reference, không bằng nội dung — React cũng vậy.

React.memo so props nông bằng Object.is, nên object literal hay arrow function mới mỗi lần render nghĩa là props khác nhau và memo không bỏ qua được. useCallback(fn, deps) bản chất là useMemo(() => fn, deps) — nên trước tiên hãy tìm prop đang đổi reference.

Kỹ thuật mạnh hơn memo mà hay bị bỏ qua là children-as-prop. Khi component re-render vì state của chính nó, props giữ nguyên. Element con tạo ở cha rồi truyền qua props.children có reference không đổi, nên React bỏ qua cả cây con.

// Trước: App giữ state của input, nên mỗi ký tự làm cả ExpensiveTree
// re-render — dù nó chẳng liên quan gì tới query.
function App() {
  const [query, setQuery] = useState("");
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ExpensiveTree />
    </>
  );
}

// Sau: state hạ xuống SearchBox. Element <ExpensiveTree /> được tạo
// ở App và truyền xuống; khi SearchBox re-render, props.children
// vẫn là cùng một reference → React bỏ qua subtree đó.
function App() {
  return (
    <SearchBox>
      <ExpensiveTree />
    </SearchBox>
  );
}

function SearchBox({ children }: { children: React.ReactNode }) {
  const [query, setQuery] = useState("");
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {children}
    </>
  );
}

“You may rely on useMemo as a performance optimization, not as a semantic guarantee.”

Câu này ở tài liệu useMemo nghĩa là React được phép vứt bỏ giá trị đã cache — thực tế khi unmount, về nguyên tắc ở bất kỳ lúc nào. Nên đừng để tính đúng/sai của chương trình phụ thuộc vào nó: đừng dùng useMemo để tạo một lần một kết nối, làm cache cho getSnapshot, hay giữ một id mang ý nghĩa key.

useMemo cũng không làm lần render đầu nhanh hơn. Lần mount chưa có gì để so sánh nên factory luôn chạy, chỉ thêm chi phí lưu cache. Giá trị của nó nằm ở các lần update sau.

Cái giá của memoize: phép so sánh chạy mọi lần render, closure bị giữ lâu hơn cần, và dependency array là hợp đồng viết tay không compiler nào kiểm hộ. Nó phản tác dụng khi props vẫn đổi mỗi render, khi component vốn rẻ, hoặc khi team đã bật React Compiler.

7. Derived state và chỗ đặt state

Hai lỗi thiết kế state gây phần lớn re-render thừa, và cả hai không liên quan tới memo.

Lỗi một: đồng bộ giá trị dẫn xuất bằng effect.

// Sai: `total` hoàn toàn tính được từ props, nhưng lại được giữ
// trong state và đồng bộ bằng effect.
function PriceTable({ items, taxRate }: { items: Item[]; taxRate: number }) {
  const [total, setTotal] = useState(0);

  useEffect(() => {
    setTotal(items.reduce((n, i) => n + i.price, 0) * (1 + taxRate));
  }, [items, taxRate]);

  return <strong>{total}</strong>;
}

Đoạn này render hai lần mỗi thay đổi — lần đầu với total cũ, commit, effect chạy, setState, render lại — và giữa hai lần đó là một trạng thái trung gian sai. Cách đúng là tính ngay trong render:

function PriceTable({ items, taxRate }: { items: Item[]; taxRate: number }) {
  // Không state phụ, không effect, không frame trung gian sai.
  const total = items.reduce((n, i) => n + i.price, 0) * (1 + taxRate);
  return <strong>{total}</strong>;
}

Nếu phép tính thật sự đắt — đo trước — thì useMemo là công cụ đúng, và lúc đó nó chỉ là tối ưu, không phải nơi giữ sự thật.

Lỗi hai: state nằm cao hơn mức cần thiết. State nên sống ở tổ tiên chung thấp nhất của những component thực sự đọc nó. Một ô filter trong modal mà state ở component trang thì cả bảng re-render theo từng ký tự. Ngoại lệ là state buộc phải ở trên, như selection của bảng vì header cần nó.

Và key quay lại đúng vai trò khi muốn reset state theo dữ liệu: <ProfileForm key={userId} userId={userId} /> nói thẳng ý định đó, rõ hơn một useEffect theo dõi userId rồi reset từng field.

8. Thực chiến: bảng 2,000 dòng, một ô input

Bài toán: bảng 2,000 dòng, mỗi dòng có checkbox chọn và input sửa số lượng, selection sống sót qua filter, và gõ trong một ô không làm gì khác re-render.

Cách làm sai rất phổ biến: state nằm hết ở component bảng, gồm bản nháp đang gõ của từng dòng trong Record<string, string>; mỗi ký tự tạo object mới nên cả bảng render lại, và memo Row không cứu được.

Cách đúng: phân loại state theo ai đọc nó — selection liên dòng thuộc component bảng, bản nháp gõ dở thuộc chính row, dữ liệu đã lưu thuộc store chuẩn hoá theo id; và để memo hoạt động, props truyền xuống phải là primitive hoặc reference ổn định.

type Row = { id: string; name: string; qty: number };

const RowItem = memo(function RowItem({
  id,
  selected,
  onToggle,
}: {
  id: string;
  selected: boolean;          // primitive → shallow compare hoạt động
  onToggle: (id: string) => void;
}) {
  const row = useStore((s) => s.byId[id]);       // dòng tự đọc dữ liệu của nó
  const [draft, setDraft] = useState(row.name);  // state nháp sống trong row

  return (
    <li>
      <input type="checkbox" checked={selected} onChange={() => onToggle(id)} />
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
      <span>{row.qty}</span>
    </li>
  );
});

function List({ ids }: { ids: string[] }) {
  const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());

  // Callback ổn định suốt vòng đời List → không phá memo của RowItem.
  const toggle = useCallback((id: string) => {
    setSelected((prev) => {
      const next = new Set(prev); // tạo Set mới, không mutate state cũ
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);

  return (
    <ul>
      {ids.map((id) => (
        <RowItem
          key={id}                      // identity theo dữ liệu, không theo index
          id={id}
          selected={selected.has(id)}   // boolean, không phải Set
          onToggle={toggle}
        />
      ))}
    </ul>
  );
}

Ba quyết định gánh hiệu năng: selected.has(id) tính ngay chỗ truyền prop để thành boolean (truyền cả Set xuống thì mọi dòng thấy prop mới sau mỗi lần tick); toggle có dependency rỗng nên reference không đổi; dữ liệu dòng đọc bằng selector trong row.

Cái giá phải nói thật: mỗi lần tick, List vẫn re-render, tạo 2,000 element và 2,000 phép so sánh props — chấp nhận được ở 2,000 dòng, nhưng là O(n) và lộ ra ở 20,000 dòng, lúc đó phải chuyển selection vào store.

Tới một ngưỡng thì mọi thứ trên là tối ưu sai chỗ. 20,000 DOM element thì browser phải style và layout hết, không memo nào chạm tới. Lúc đó cần virtualization: chỉ render khoảng 30 dòng đang nhìn thấy cộng spacer giữ chiều cao. Cái giá là phải đo dòng cao động, find-in-page ngừng hoạt động, và dùng index làm key trong list ảo thì key đổi khi cuộn — đúng bug ở mục 2 quay lại.

useDeferredValue giải đúng bài “giữ input mượt khi danh sách nặng”: React render danh sách ở ưu tiên thấp hơn, cho input cập nhật trước, hoặc vứt lần render dở nếu người dùng gõ tiếp.

9. Checklist khi UI chậm hoặc state nhảy

Đừng bắt đầu bằng useMemo.

  1. Xác định triệu chứng: DOM quá nhiều, layout làm lại liên tục, animation giật — không phải bài toán re-render; state sai chỗ hoặc mất sau thao tác danh sách — bug identity.
  2. Kiểm tra key: key={index}, key sinh trong render, component định nghĩa bên trong component.
  3. Profile một tương tác cụ thể: component nào total time lớn, render vì state của chính nó, cha hay context.
  4. Kiểm tra chỗ đặt state, các cặp useState + useEffect tính được ngay trong render, và identity của props/value (object/array/function literal trong JSX, value context chưa memo, selector trả object mới).
  5. Chỉ lúc này mới tới memo/useMemo/useCallback, chỉ với component đã chứng minh đắt; DOM vẫn quá nhiều thì window hoá.

Re-render đắt không phải vì số lần render nhiều. Đắt vì React không nhận ra “đây vẫn là cùng một thứ”. Mỗi lần câu trả lời là không, React làm lại từ đầu. Tệ nhất là reset đúng state người dùng đang gõ dở.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.