Phỏng Vấn & Sự Nghiệp • 11 phút đọc • 05/10/2026

Phỏng Vấn Deep Technical React: Mental Model Đúng Thay Vì Thuộc Lòng

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

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Bảng trắng vẽ sơ đồ
Ảnh: Slcreza · CC BY-SA 4.0

Vòng deep technical React hay mở bằng một câu nghe rất hiền: “setState có async không?”. Trả lời đúng cú pháp thì dễ. Đúng cơ chế mới khó.

Dưới đây là mười câu mình gặp nhiều nhất khi ngồi ghế interviewer. Mỗi câu ba phần: interviewer hỏi gì, câu trả lời kiểu middle nghe hợp lý mà thiếu cơ chế, rồi câu trả lời senior bắt đầu từ mental model và suy ra hệ quả lúc hệ thống lớn lên.

1. “setState có async không?”

Interviewer hỏi: “setState trong React có phải async không?”

Câu trả lời middle: “Có, nó async nên phải đọc giá trị mới trong callback hoặc useEffect.” Sai cơ chế.

Câu trả lời senior: setState không trả về Promise, cũng chẳng đi qua microtask. Ở đây có hai chuyện khác nhau hay bị gộp làm một. Batching: nhiều update trong cùng một tick gộp thành một lần re-render. Ngữ nghĩa snapshot: biến state bạn đọc là const của lần render hiện tại, nên setCount(count + 1) rồi console.log(count) vẫn in giá trị cũ. Vẫn cũ.

Từ React 18, automatic batching áp dụng cả cho update trong promise, setTimeout và native event handler. Trước 18 thì chỉ update trong event handler của React mới được batch. Code nào giả định “render xảy ra ngay sau setState” sẽ vỡ lúc nâng cấp. Cần đọc DOM ngay sau update thì flushSync là cửa opt-out, mà dùng thường xuyên nghĩa là bạn đang chống lại model của React.

import { flushSync } from "react-dom";

function handleAdd() {
  setItems((prev) => [...prev, nextItem]);
  console.log(items.length); // vẫn là giá trị của render hiện tại

  flushSync(() => setOpen(false)); // DOM đã cập nhật sau dòng này
  panelRef.current?.scrollTo({ top: 0 });
}

2. Ba lần setState trong một handler

Interviewer hỏi: “Gọi ba lần setCount(count + 1) trong một handler thì count tăng mấy?”

Câu trả lời middle: “React gộp nên chỉ render một lần, giá trị cộng ba.” Nửa đầu đúng, nửa sau sai.

Câu trả lời senior: mỗi lần gọi setState đẩy một update vào queue. Ba update cùng đọc count của render hiện tại nên cùng mang giá trị count + 1. React chạy queue theo thứ tự, cái sau ghi đè cái trước, kết quả tăng 1. Muốn tăng 3 thì dùng functional update setCount((c) => c + 1), để mỗi updater nhận giá trị mới nhất đã tính trong queue — đúng hệ quả của snapshot semantics ở mục 1.

Chuyện “một re-render” cũng cần nói cho chính xác. Giá trị mới bằng giá trị cũ theo Object.is thì React bỏ qua update, nhưng nó vẫn có thể render lại component một lần trước khi bail out. Đừng coi “setState cùng giá trị = zero render” là hợp đồng. Và với state là object/array, Object.is so sánh reference nên setState({ ...state }) luôn tạo render mới.

// Sai: ba update cùng đọc count của render này -> count = 1
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);

// Đúng: mỗi updater nhận giá trị mới nhất trong queue -> count = 3
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
flowchart LR
  E["onClick"] --> A["setA(1)"] & B["setB(2)"] & C["setC(3)"]
  A --> Q["Batch queue"]
  B --> Q
  C --> Q
  Q --> R["MỘT lần re-render<br/>React 18: cả trong<br/>promise / setTimeout"]

3. Stale closure: interval vẫn log giá trị cũ

Interviewer hỏi: “Vì sao interval của em vẫn log ra giá trị cũ của state?”

Câu trả lời middle: “Chắc do dependency array, thêm biến đó vào deps là hết.” Hết triệu chứng, nhưng với interval hay socket thì cách đó còn gây re-subscribe sai.

Câu trả lời senior: mỗi lần render, React tạo một closure mới cho component. Mọi biến state trong đó là const của lần render ấy. Callback đăng ký ở render thứ nhất sẽ mãi nhìn thấy dữ liệu của render thứ nhất, cho tới khi nó được đăng ký lại. Stale closure là hệ quả của snapshot semantics, chứ không phải lỗi riêng của useEffect.

Effect thiếu dependency thì giữ closure cũ. exhaustive-deps sinh ra để ép bạn thấy effect phụ thuộc cái gì, mà khi nó phụ thuộc gần như mọi thứ thì thường là effect đang làm sai việc. Khi cần giá trị mới nhất mà không muốn re-subscribe (handler WebSocket, interval, subscriber thư viện ngoài), pattern latest-ref là công cụ đúng: giữ callback trong ref, cập nhật ref trong effect. React 19.2 có useEffectEvent cho đúng ca này.

function useInterval(callback: () => void, delay: number | null) {
  const saved = useRef(callback);

  useEffect(() => {
    saved.current = callback; // cập nhật trong effect, không phải trong render
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => saved.current(), delay);
    return () => clearInterval(id);
  }, [delay]); // chỉ thiết lập lại interval khi delay đổi
}

4. useEffect không phải lifecycle

Interviewer hỏi: “useEffect tương đương componentDidMount hay componentDidUpdate?”

Câu trả lời middle: “Nó là cả hai: deps rỗng như componentDidMount, có deps như componentDidUpdate.” Câu này phổ biến nhất, mà cũng là nguồn gốc phần lớn bug effect.

Câu trả lời senior: useEffect không phải một mốc trong vòng đời component. Nó là chỗ đồng bộ một hệ thống ngoài React với props/state hiện tại. Việc bạn làm mà không đụng gì ngoài React — không DOM, network, subscription, timer — thì thường không cần effect. Giá trị dẫn xuất hãy tính trong render, còn “state phái sinh từ props” là lỗi thiết kế.

Effect thường chạy sau khi browser paint. Cleanup chạy trước lần effect kế tiếp và một lần cuối khi unmount; nó hoàn tác lần đồng bộ trước, chứ không phải “hàm huỷ khi component chết”. StrictMode ở dev gọi mount → unmount → mount để phơi ra effect thiếu cleanup: subscription chồng nhau, request không huỷ, listener rò rỉ. Ở production thì không có bug “chạy hai lần” nào đâu.

useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();
  return () => connection.disconnect(); // chạy trước lần effect kế và khi unmount
}, [roomId]);

5. Race condition khi fetch trong effect

Interviewer hỏi: “Em fetch dữ liệu trong useEffect thế nào?”

Câu trả lời middle: “Em viết một async function trong effect rồi setState khi có kết quả.” Chạy được — và đó là chỗ race bắt đầu.

Câu trả lời senior: mỗi lần deps đổi là một request mới, mà chẳng gì bảo đảm response về đúng thứ tự. Gõ “re”, “rea”, “react” tạo ba request song song. Cái của “re” có thể về sau cùng rồi ghi đè UI bằng kết quả sai. Kiểu last-write-wins này là bug production kinh điển: máy dev latency thấp nên gần như không tái hiện được.

Hai lớp phòng vệ, dùng cùng lúc. AbortController huỷ request đang bay (abort làm fetch reject với AbortError), và cờ ignore chặn setState từ lần chạy đã cũ — cờ vẫn cần vì abort là best-effort. Effect callback không được là async function, và lỗi phải xử lý trong chính hàm async. Ở tầm hệ thống, cache, dedupe, cancel và stale-while-revalidate là bài toán hạ tầng; mình để TanStack Query lo.

useEffect(() => {
  const controller = new AbortController();
  let ignore = false;

  async function load() {
    try {
      const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
        signal: controller.signal,
      });
      const data = await res.json();
      if (!ignore) setResults(data);
    } catch (err) {
      if ((err as Error).name !== "AbortError") setError(err as Error);
    }
  }

  load();
  return () => {
    ignore = true; // chặn setState từ lần chạy đã cũ
    controller.abort();
  };
}, [query]);

6. useMemo, useCallback và memo: khi nào mới đáng

Interviewer hỏi: “Khi nào em dùng useMemo?”

Câu trả lời middle: “Dùng để tránh tính lại, bọc useCallback/memo để component con không re-render.” Thiếu cả chi phí lẫn giới hạn của hợp đồng.

Câu trả lời senior: chính tài liệu React nói: “You may rely on useMemo as a performance optimization, not as a semantic guarantee.” React có thể vứt bỏ giá trị đã cache bất cứ lúc nào, nên code phải đúng kể cả khi useMemo không tồn tại. Nếu tính đúng/sai phụ thuộc vào nó — khởi tạo instance, đăng ký một lần, giữ identity nghiệp vụ — thì đó là bug thiết kế. Công cụ đúng là useRef, lazy initializer của useState, hoặc biến module scope.

Và useMemo không làm lần render đầu nhanh hơn. Nó chỉ bỏ qua công việc ở các lần update sau, còn bản thân nó cũng tốn: giữ dependency array, so sánh mỗi render, giữ closure. memo chỉ có tác dụng nếu props ổn định; cha truyền object/function mới mỗi render thì nó render như thường.

// Không cần memo: giá trị dẫn xuất rẻ
const total = items.reduce((sum, item) => sum + item.price, 0);

// Cần memo: phép tính đắt và deps ổn định
const ranked = useMemo(
  () => rows.slice().sort((a, b) => b.score - a.score),
  [rows],
);

7. useState vs useRef: khác nhau ở hợp đồng

Interviewer hỏi: “useRef khác useState chỗ nào?”

Câu trả lời middle: “useRef để lấy DOM node, useState để lưu giá trị.” Đúng nửa công dụng, sai trọng tâm.

Câu trả lời senior: ref là escape hatch cho hai việc. Một, trỏ tới DOM node. Hai, giữ giá trị mutable sống qua các lần render mà không trigger re-render. Khác biệt nằm ở hợp đồng: state tham gia vào render, đổi state thì UI mô tả lại được; còn đổi ref thì không làm gì cả, nên UI không được phép phụ thuộc vào nó.

Vì vậy đừng đọc và đừng ghi ref.current trong lúc render. Render phải pure, vì ở chế độ concurrent React có thể gọi render nhiều lần, chạy lại hoặc vứt kết quả đi; ghi ref trong render sẽ được thực thi với số lần không xác định. Ngoại lệ duy nhất là lazy init: ref.current === null thì gán một lần. Ghi ref trong event handler hoặc effect là bình thường, và latest-ref ở mục 3 là ví dụ.

// Sai: mutate trong render — số lần chạy không xác định ở concurrent/StrictMode
function Bad() {
  const renders = useRef(0);
  renders.current += 1;
  return <p>{renders.current}</p>;
}

8. Context: value đổi là mọi consumer re-render

Interviewer hỏi: “Context có làm mọi component re-render không?”

Câu trả lời middle: “Không, chỉ component nào gọi useContext thôi.” Nửa đúng, thiếu phần quan trọng.

Câu trả lời senior: nói cho chính xác: khi value của Provider đổi thì mọi consumer của context đó re-render, bất kể có dùng tới phần vừa đổi hay không. Context gộp user, theme, cart sẽ khiến toàn bộ consumer render lại khi một field đổi. Component ở giữa không gọi useContext thì không re-render, nhưng vẫn bị reconcile khi cha render.

Một chỗ hay bất ngờ: value tạo inline như value={{ user, theme }} có reference mới mỗi lần Provider render, nên consumer re-render dù dữ liệu y hệt — đây là ca useMemo cho value thật sự có nghĩa.

Xử lý tăng dần: tách context theo tần suất thay đổi, memo hoá value, và khi update tần suất cao thì bỏ broadcast để dùng store có selector. useSyncExternalStore đăng ký từng subscriber, component chỉ re-render khi phần nó chọn đổi. Store như Zustand có mặt không phải vì context chậm, mà vì context không có selector.

// Mỗi lần Provider render, value là object mới -> mọi consumer re-render
<UserContext.Provider value={{ user, theme, unread }}>…</UserContext.Provider>

// Tách theo tần suất thay đổi: unread đổi liên tục, user/theme thì không
<UserContext.Provider value={user}>
  <ThemeContext.Provider value={theme}>
    <UnreadContext.Provider value={unread}>…</UnreadContext.Provider>
  </ThemeContext.Provider>
</UserContext.Provider>

9. Concurrent rendering, render bị ngắt và tearing

flowchart TB
  U["User input<br/>(ưu tiên cao)"] --> R1["Render khẩn cấp"]
  T["useTransition / useDeferredValue<br/>(ưu tiên thấp)"] --> R2["Render có thể bị<br/>ngắt & restart"]
  R2 -. "bị ngắt" .-> R3["Render lại từ đầu<br/>→ render phải PURE"]
  R1 --> CM["Commit: mutate DOM"]
  R3 --> CM
  CM --> UI["UI cập nhật"]
  EXT["External store"] --> SE["useSyncExternalStore<br/>chống tearing"]
  SE --> UI

Interviewer hỏi: “Concurrent rendering trong React 18 giải quyết vấn đề gì?”

Câu trả lời middle: “Nó làm render nhanh hơn, và useTransition dùng để debounce input.” Cả hai vế đều sai.

Câu trả lời senior: concurrent rendering không làm render nhanh hơn. React bắt đầu một render, có thể bị ngắt bởi update khẩn cấp hơn, rồi bắt đầu lại. Đổi lại, render phải pure: mọi mutation biến ngoài trong render — ghi ref, mutate biến module-level, tăng counter — cho kết quả không xác định khi render bị chạy lại hoặc vứt đi.

Tearing là rủi ro khi React đọc nguồn dữ liệu ngoài React: hai component đọc cùng store có thể nhận hai phiên bản state khác nhau trong cùng một frame. useSyncExternalStore là API chuẩn để tránh, React đăng ký nhận thông báo từ store và kiểm tra lại snapshot khi render.

useTransition và useDeferredValue đánh dấu update không khẩn cấp. Cần chốt một câu: transition là cơ chế ưu tiên, chứ không phải debounce. Nó không chờ đủ X mili-giây, không gộp nhiều lần gõ phím thành một lần chạy, không huỷ request. Nó chỉ cho React bỏ dở lần render này nếu có việc gấp hơn.

const [input, setInput] = useState("");
const [filter, setFilter] = useState("");
const [isPending, startTransition] = useTransition();

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  setInput(e.target.value); // urgent: input phải khớp phím ngay
  startTransition(() => {
    setFilter(e.target.value); // non-urgent: được phép bị ngắt
  });
}

10. Custom hook và lazy initializer: ai sở hữu state?

Interviewer hỏi: “Hai component dùng chung custom hook có chung state không? useState(() => init()) khác gì useState(init())?”

Câu trả lời middle: “Dùng chung hook thì chia sẻ logic; lazy init để chạy nhanh hơn.” Vế đầu sai ở chữ state.

Câu trả lời senior: custom hook chỉ là một hàm gọi các hook khác. Nó tái sử dụng logic, không tái sử dụng state. Mỗi lần component gọi hook, state và effect bên trong thuộc về fiber của component đó — hai component gọi cùng hook có hai vùng state độc lập. Muốn chia sẻ state thật thì phải nâng lên cha, dùng context hoặc store ngoài. Hook phải được gọi vô điều kiện ở cấp cao nhất, vì React khớp hook theo thứ tự gọi.

Còn lazy initializer: useState(expensive()) gọi expensive ở mọi lần render rồi vứt kết quả sau lần đầu, nên chi phí vẫn bị trả mỗi render. useState(() => expensive()) truyền một hàm, React chỉ gọi nó khi cần giá trị khởi tạo — một lần cho mỗi lần mount, và hàm init phải pure. Với init đọc localStorage hay build map từ danh sách dài, khác biệt đo được trong Profiler.

// Chạy mỗi lần render, kết quả bị bỏ sau lần đầu
const [index] = useState(buildSearchIndex(rows));

// Chỉ chạy một lần cho mỗi lần mount
const [index2] = useState(() => buildSearchIndex(rows));

Kết: key, identity và cách trả lời

Câu sàng lọc hay gặp: “vì sao danh sách bị lỗi input sau khi sort?” Trả lời phải bắt đầu từ identity trong reconciliation. React so khớp element theo vị trí trong cây và theo key, nên key chính là danh tính của instance. key={index} ổn khi danh sách chỉ thêm ở cuối, nhưng reorder hay sort thì phần tử ở vị trí 1 đổi thành phần tử khác mà key vẫn là 1 — React coi đó là cùng một instance và giữ state cũ, khiến input gắn nhầm sang item khác. Đổi key là ép mount một instance mới, cách gọn nhất để reset state của form khi đổi user. Cây so khớp mình đã bàn kỹ ở bài deep technical về reconciliation trong blog này.

Khung mình dùng vẫn là bốn bước: mental model của React là gì, hành vi suy ra từ model đó, hệ quả khi hệ thống lớn lên, rồi cách kiểm chứng. Interviewer senior đào bằng câu “vậy nếu … thì sao”, nên trả lời bằng tên API không thì hết đường nhanh. Phần đo lường LCP/INP/CLS mình viết riêng ở bài render performance.

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ờ.