Hub

React

Bài này là cầu nối Vue → React cho phỏng vấn, trên React 19: re-render khác fine-grained reactivity, khi nào effect là bug, khi nào memo chỉ để cho có, và data nên load ở đâu. Dành cho engineer đã ship Vue, giờ cần quyết được chuyện identity, Fiber, và hydration.

Đọc 20.1 trước (reconciliation, JSX, props khác state, effects, refs, fetch). Người phỏng vấn hỏi xoáy vì sao parent re-render kéo cả cây chạy, vì sao useEffect không phải onMounted, và document nên load ở đâu, chứ không phải trivia Hook. Rồi 20.2 cho Context, Suspense, Error Boundaries, và update concurrent. Bản Vue nằm ở tài liệu Vue 3 và State Management. Nêu rõ ràng buộc, lỗi bạn đã gặp, và cách bạn chứng minh fix.


Tóm tắt: Mô hình React là UI = f(state). Khi state đổi, hàm chạy lại, React diff cây element mới với cây trước đó (reconciliation), rồi commit mutation DOM. Fiber là linked-list các work unit, nhờ đó pha render tạm dừng được.

Vì sao quan trọng: Dashboard Vue 3 mutate một ref thì chỉ widget đó chạy lại. Cùng màn bên React, parent và mọi child re-render khi count đổi. Họ muốn nghe vì sao, bạn còn bọc hết memo không nếu React Compiler đang bật, và key làm gì khi list đổi thứ tự.

  1. Cách hoạt động: State, props, hoặc context đổi. React chạy lại hàm component (render phase). Pha đó là pure, và với Fiber thì ngắt được.
  2. Reconciliation khớp element type + position, list thì theo key. Cùng type và cùng key thì update tại chỗ (giữ state). Khác type hoặc khác key thì unmount rồi remount (reset state).
  3. commit phase áp dụng mutation DOM đồng bộ. Child re-render vì parent đã chạy, trừ khi nó bail out (memo, useMemo identity, hoặc Compiler).
  4. Tương đương Vue: Vue 3 patch đồ thị reactivity, nên một ref không kéo cả cây chạy lại. React chạy lại hàm, rồi diff.
// Cùng type + cùng key → update tại chỗ. Type mới hoặc key mới → remount.
{user ? <Editor key={user.id} /> : <Guest />}

// memo so sánh props. Object/hàm mới mỗi render → không bailout
const Child = memo(Expensive)
function Parent() {
  const [n, setN] = useState(0)
  return (
    <Child style={{ margin: 0 }} onClick={() => save(n)} />
  )
}

Điều gì xảy ra: Đổi Editor sang Guest là đổi type, nên state local mất. Child đã được memo, nhưng style={{}} và onClick={() => …} là identity mới mỗi lần Parent render, nên memo không bao giờ bail out. Key trên Editor giữ draft khi user.id ổn định.

Vue 3React 19
Mô hình updatePatch đồ thị reactivityChạy lại hàm, rồi diff
Identity của list:keykey trên element trong array
BailoutDeps không dùng thì không chạy lạimemo / Compiler / cùng state ref
FiberScheduler tách riêng (Vue 3.5)Work unit liên kết; render tạm dừng được

Góc nhìn senior

  • Viết Vue thường thấy rẻ hơn. React là snapshot, và RSC / Compiler / concurrent tồn tại vì re-render mặc định đắt khi scale.
  • Bọc memo tràn lan thì thêm so sánh nông, vẫn gãy vì props là object/hàm mới. Đo bằng Profiler trước.
  • React Compiler (opt-in trong React 19) có thể memo giúp bạn. Nếu nó đã làm rồi, chồng thêm memo tay chỉ là nhiễu.
  • Context consumer không bail out khi value đổi. Tách context, hoặc để value hay đổi vào store.
  • Fiber cho reconciliation tạm dừng. Việc đó không đổi mô hình update Vue khác React; xem topic concurrent ở 20.2.10.

Lỗi hay gặp

  • Triệu chứng: sửa một ô mà mọi row re-render. Nguyên nhân: parent giữ list và child không bail out. Cách xử lý: key ổn định, rồi Profiler, rồi memo hoặc Compiler, đừng đoán mò.
  • Triệu chứng: memo không bao giờ skip. Nguyên nhân: style={{}} hoặc onClick inline. Cách xử lý: identity ổn định, hoặc thôi bọc.
  • Triệu chứng: wizard mất draft khi object user bị thay. Nguyên nhân: key={user} dùng identity của object. Cách xử lý: key={user.id}.
  • Triệu chứng: đổi type từ Foo sang Bar mà input cũ vẫn còn. Phải remount. Nguyên nhân: tái dùng cùng key trên type khác, cùng vị trí.

Người phỏng vấn hỏi gì

Bạn phụ trách một dashboard Vue 3: mutate một ref thì chỉ widget đó chạy lại. Cùng màn bên React, parent và mọi child re-render khi count đổi. Vì sao, bạn còn bọc hết memo không nếu React Compiler đang bật, và key làm gì khi list đổi thứ tự?

Cách trả lời lúc phỏng vấn

React là UI bằng f của state: hàm chạy lại, rồi reconcile theo type và key rồi commit. Vue patch đồ thị reactivity, nên một ref không kéo cả cây chạy lại. Child re-render vì parent đã chạy trừ khi bail out, và Context consumer không bail out khi value đổi. key là identity xuyên các lần render, không phải gợi ý hiệu năng. Mình không bọc hết memo; mình đo Profiler trước. Nếu React Compiler đang bật, chồng memo tay thường chỉ là nhiễu. Fiber là linked-list các work unit để render tạm dừng được; commit vẫn đồng bộ.

Nhớ nhanh

  • React chạy lại rồi diff. Vue theo dõi deps và chạy lại subscriber.
  • key là identity xuyên render, không phải gợi ý hiệu năng.
  • Fiber: work unit liên kết; render tạm dừng được; commit thì sync.
  • Compiler đổi mặc định từ “bọc hết” sang “đo, giữ render thuần”.

Câu hỏi nối tiếp

  • Đi từng bước reorder list, có và không có key ổn định? Key theo index tái dùng nhầm state local. Xem 20.1.6.
  • memo có skip reconciliation của descendant không? Nó skip chạy hàm đó. Nếu nó render, children vẫn chạy trừ khi chúng cũng bail out.
  • Fiber có đổi mô hình Vue khác React không? Không. Render ngắt được không biến React thành fine-grained. Xem 20.2.10.
  • Vì sao đổi Foo sang Bar thì mất state local? Element type khác ở vị trí đó unmount fiber cũ.

Tóm tắt: React mới là function component và Hooks. State của Hook nằm thành một list trên fiber, ghép theo thứ tự gọi. Error Boundaries trong core React 19 vẫn là class (componentDidCatch).

Vì sao quan trọng: Đồng đội viết if (enabled) useEffect(...), hỏi class đã chết chưa trừ Error Boundaries, và muốn map 1:1 từ mixin Options API sang Hooks. Lời gọi Hook sai luật mới là bug thật.

  1. Cách hoạt động: Mỗi render, React đi lần lượt các lời gọi Hook theo thứ tự và gắn slot useState / useEffect lên fiber. Thứ tự gọi phải giống nhau mọi render. Đó là lý do có eslint-plugin-react-hooks.
  2. if (enabled) useEffect(...) nghĩa là sau khi enabled đảo, mọi Hook phía sau thành slot khác. State gắn nhầm lời gọi. Thân effect có thể có điều kiện; lời gọi Hook thì không.
  3. Class vẫn dùng cho cây legacy và Error Boundaries. Core React vẫn cần componentDidCatch / getDerivedStateFromError. Tách class đó ra (hoặc react-error-boundary) và giữ phần còn lại là function.
  4. Tương đương Vue: <script setup> giống function component. Mixin Options API là bẫy class/mixin mà Hooks ra đời để thoát.
// Sai luật: sau khi enabled đảo, mọi Hook phía sau thành slot khác
if (enabled) useEffect(() => subscribe(), [])
const [q, setQ] = useState('')

// Hợp lệ: Hook luôn chạy; thân effect mới có điều kiện
useEffect(() => {
  if (!enabled) return
  return subscribe()
}, [enabled])

Điều gì xảy ra: Render đầu với enabled === true, useEffect là slot 0 và useState là slot 1. Sau khi enabled thành false, slot 0 giờ là useState: React đọc string chỗ nó đã lưu effect. Bản hợp lệ luôn gọi cả hai Hook.

VueReact
<script setup>Function component + Hooks
Options APIClass component (legacy)
Mixins / extendsCustom Hooks (compose)
onErrorCapturedError Boundary class (componentDidCatch)
Thứ tự gọi tự doThứ tự Hook là địa chỉ state

Góc nhìn senior

  • Ship function components + Hooks. Đừng viết “class cho mọi thứ vì Error Boundaries”: tách đúng một class.
  • Hooks compose mà không dính mixin hell, cùng lý do bạn rời mixin Options API. Helper tên onlineStatus() mà gọi useState vẫn là Hook và phải theo luật.
  • eslint-plugin-react-hooks (rules-of-hooks, exhaustive-deps) là chuyện đúng, không phải style. use* nằm sau if thì reject lúc review.
  • Không gọi Hooks trong class. Không gọi trong event handler, vòng lặp, hoặc setTimeout.
  • useEffectEvent (React 19) là lối thoát cho event ổn định trong effect, không phải eslint-disable exhaustive-deps.

Lỗi hay gặp

  • Triệu chứng: state lấy nhầm field sau toggle. Nguyên nhân: Hook nằm sau if. Cách xử lý: luôn gọi Hook; để điều kiện ở thân.
  • Triệu chứng: “Hooks can only be called inside a function component”. Nguyên nhân: use* trong helper thuần hoặc class. Cách xử lý: đổi tên thành useOnlineStatus và gọi từ component.
  • Triệu chứng: cả cây là class “vì lỗi”. Nguyên nhân: trộn UI với componentDidCatch. Cách xử lý: một wrapper Error Boundary.
  • Triệu chứng: tắt exhaustive-deps để capture props trong closure. Nguyên nhân: effect nên dùng useEffectEvent hoặc thêm dep.

Câu người phỏng vấn hay hỏi

Một bạn trong team viết if (enabled) useEffect(...), hỏi class component đã chết chưa trừ Error Boundaries, rồi muốn map 1:1 từ mixin Options API sang Hooks. Vì sao lời gọi Hook này sai luật, chỗ nào trong core React vẫn bắt buộc class, và làm sao migrate cái thói mixin mà không rơi vào mixin hell?

Cách trả lời khi phỏng vấn

Code mới thì dùng function kèm Hooks. Class chỉ còn cho cây legacy và Error Boundaries: core React 19 vẫn cần componentDidCatch, nên mình dùng react-error-boundary rồi để phần còn lại là function. Hooks gắn theo vị trí trên fiber; if (enabled) useEffect là cách state phía sau dính nhầm slot. Thân effect được phép có điều kiện; lời gọi Hook thì không. script setup của Vue là analogue; mixin Options API chính là thứ Hooks thay thế. eslint-plugin-react-hooks là tính đúng. Mình không gọi Hooks trong class, vòng lặp, hay helper không phải Hook.

Nhớ nhanh

  • Thứ tự gọi Hook là địa chỉ state trên fiber.
  • Error Boundaries trong core React 19 vẫn là class; tách chúng ra riêng.
  • eslint-plugin-react-hooks là tính đúng. use* đứng sau if thì reject.
  • Vue <script setup> ≈ function component; mixin ≈ cái bẫy Hooks đã bỏ lại.

Câu hỏi tiếp theo hay gặp

  • Implement một Error Boundary? Họ muốn các method của class, và những thứ nó không bắt (events, async, SSR). Xem 20.2.4.
  • Gọi Hooks trong class được không? Không. Đưa state sang một function child, hoặc custom Hook mà child dùng.
  • Vì sao onlineStatus() gọi useState tệ hơn if (x) useState()? Nó vẫn là Hook nhưng tên gây hiểu nhầm, nên chỗ gọi bỏ qua rules.
  • Khi nào useEffectEvent mới là lối thoát đúng? Callback lấy props mới nhất, nằm trong effect mà không nên subscribe lại. Không phải eslint-disable tràn lan.

Tóm tắt: JSX biên dịch thành lời gọi hàm (jsx() / React.createElement). Re-render nghĩa là gọi lại hàm component và trả về mô tả UI mới, không mutate DOM tại chỗ như update fine-grained của Vue.

Vì sao quan trọng: Badge phải ẩn khi bằng 0, nhưng UI lại in ra 0. Ngay cạnh đó, hai sibling trong map bị remount hoặc warn, và trang Next hydrate sai vì <p><div>. Họ muốn phân biệt null với false với 0, và <> có nhận key không.

  1. Cách hoạt động: JSX là mô tả UI. {false}, {null}, và {undefined} không render gì. {0}, {NaN}, và {""} render thành text. Boolean bị bỏ qua; số thì không. {count && <Badge />} khi count === 0 sẽ vẽ 0.
  2. Bạn trả null khi kiểm soát API và muốn nói “không gì”. Fragment nhóm mà không tạo node DOM. Chỉ <Fragment key> mới nhận key; cú pháp ngắn <> thì không.
  3. Lồng HTML không hợp lệ (<p><div>) lúc local vẫn pass được, rồi gãy hydration trên server, vì browser sửa cây trước khi React hydrate.
  4. Tương đương Vue: Template Vue biên dịch thành render function kèm patch flag. JSX trong React là lời gọi hàm chưa tối ưu, trừ khi Compiler chạy. v-if không vẽ 0.
{count && <Badge />}            // bẫy: count=0 render "0"
{count > 0 ? <Badge /> : null}  // tường minh, nên dùng
{count ? <Badge /> : null}

// Chỉ Fragment nhận key; <> ngắn thì không
{items.map((item) => (
  <Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.def}</dd>
  </Fragment>
))}

Điều gì xảy ra: 0 && <Badge /> ra số 0, React render thành text node. Ternary trả null nên không vẽ gì. Hai sibling trong list cần Fragment có key, không thì remount / warn. <> ngắn không mang được identity đó.

Giá trị trong {…}Render thành
false / null / undefinedKhông gì
trueKhông gì (boolean bị bỏ qua)
0 / NaN / ""Text node
Element / componentCây đó

Góc nhìn senior

  • && viết ngắn nhưng không an toàn với số. Boolean hoặc ternary thì rõ ràng. Mình trả null khi chịu trách nhiệm API của component.
  • v-show không có primitive: CSS hidden hoặc unmount. Đừng giả toggle display bằng &&.
  • Ưu tiên Fragment hơn div bọc khi layout, HTML hợp lệ (<tr>), hoặc a11y quan tâm list con.
  • Warning hydration trên console là fail, không phải nhiễu. Lồng HTML sai là nguyên nhân hay gặp trên Next.
  • undefined khi là prop nghĩa là “dùng mặc định”. undefined khi là child nghĩa là “không render”. Hai cái không giống nhau.

Lỗi hay gặp

  • Triệu chứng: hiện 0 chỗ badge phải ẩn. Nguyên nhân: {count && <Badge />}. Cách xử lý: {count > 0 ? <Badge /> : null}.
  • Triệu chứng: warn list hoặc remount <dt>/<dd>. Nguyên nhân: <> ngắn trong map. Cách xử lý: <Fragment key={id}>.
  • Triệu chứng: lệch hydration Next trên một paragraph. Nguyên nhân: <p><div>. Cách xử lý: lồng HTML hợp lệ; coi warning là fail.
  • Triệu chứng: khoảng trống trong flex/grid. Nguyên nhân: div bọc chỗ lẽ ra phải là Fragment.

Câu người phỏng vấn hay hỏi

Badge phải ẩn khi bằng 0, nhưng UI lại in ra 0. Ngay cạnh đó, hai sibling trong map bị remount hoặc warn, và trang Next hydrate sai vì <p><div>. null, false, undefined, 0 mỗi cái render ra gì, khi nào cần Fragment, và <> có nhận key không?

Cách trả lời khi phỏng vấn

JSX là mô tả, không phải DOM. Re-render gọi lại hàm, khác patch fine-grained của Vue. false, null, và undefined không render gì; 0 và chuỗi rỗng là text, nên count && Badge in ra 0. Mình trả null khi kiểm soát API. Fragment nhóm mà không tạo node, và chỉ component Fragment mới nhận key; cú pháp ngắn thì không. Lồng HTML sai thì hydrate lệch vì browser sửa cây trước. v-if của Vue không bao giờ vẽ 0; mình không giả && là v-if.

Nhớ nhanh

  • 0 && <X /> vẽ 0. Dùng boolean hoặc ternary.
  • Chỉ <Fragment key> mang identity. <> thì không.
  • Re-render gọi lại hàm; Vue patch một graph.
  • Lồng HTML không hợp lệ thì hydrate sai. Coi warning trên console là fail.

Câu hỏi tiếp theo hay gặp

  • Vì sao dùng Fragment thay vì div bọc? Layout (con flex/grid), HTML hợp lệ (<tr>), a11y.
  • Khi nào item list gồm hai sibling cần Fragment có key? Luôn, nếu chúng chung một vòng map. Xem 20.1.6.
  • undefined khi là prop khác khi là child thế nào? Prop: áp dụng default. Child: không render gì.
  • Vì sao <p><div> pass local rồi gãy hydration trên Next? Browser sửa DOM; HTML phía server không khớp.

Tóm tắt: Props là contract chỉ-đọc từ parent chảy xuống. State do component này chịu trách nhiệm (hoặc store, URL, hoặc cache server). Update của React thay identity; Proxy của Vue thấy mutation tại chỗ.

Vì sao quan trọng: Child làm items.push(row) như một bạn Vue mutate list reactive. Parent vẫn cũ, setUser(user) sau user.name = 'x' không có tác dụng, và đưa draft lên root của trang thì dashboard re-render mỗi lần gõ phím.

  1. Cách hoạt động: Props đến từ parent. Child không được mutate chúng. Muốn đổi thì gọi onChange (hoặc store). Đó là ownership: ai được phép ghi.
  2. setState bail out nếu Object.is thấy cùng reference. user.name = 'x'; setUser(user) không làm gì. Copy: setUser({ ...user, name: 'x' }) hoặc reducer / Immer.
  3. Copy props vào state rồi sync bằng effect thường là anti-pattern derived-state. Derive lúc render, hoặc đưa chỗ ghi lên owner.
  4. Tương đương Vue: Props Vue vẫn một chiều. Proxy reactive thấy items.push; React thì không. Pinia ghi thì thay field, giống reducer.
// Không làm gì: cùng identity, Object.is bailout
user.name = 'x'
setUser(user)

// Thay: React thấy reference mới
setUser({ ...user, name: 'x' })
setList((list) => [...list, row])

// Child không chịu trách nhiệm props: nó gọi lên
function Row({ item, onRename }: { item: Item; onRename: (name: string) => void }) {
  return <input value={item.name} onChange={(e) => onRename(e.target.value)} />
}

Điều gì xảy ra: Mutate user tại chỗ thì vẫn cùng reference, nên React bỏ qua update. Spread tạo object mới nên input mới đổi được. Row không ghi item; nó hỏi owner qua onRename. Đó là lifting state.

VueReact
Props một chiềuProps một chiều (cùng contract)
Proxy thấy mutation tại chỗObject.is cùng ref → bailout
v-model / emitvalue + onChange
Pinia / ref là owneruseState / store / URL

Góc nhìn senior

  • Immutability giúp memo và Object.is rẻ. Rườm rà hơn Pinia. Immer (trong RTK hoặc viết tay) là thỏa hiệp khi team lớn.
  • Clone root của bảng 10k row mỗi lần sửa một ô mới là bug thật. Structural sharing, hoặc lưu row theo id.
  • Nếu một input leaf làm dashboard re-render, state đang sai chỗ. Lift chỉ cao bằng những chỗ đọc dùng chung.
  • config={{}} làm prop thì mỗi render một identity mới, gãy memo child “không đổi”.
  • URL, cache server, và local cùng muốn một field là câu hỏi source of truth. Xem tài liệu state-management của React.

Lỗi hay gặp

  • Triệu chứng: UI đứng sau user.name = 'x'. Nguyên nhân: cùng reference. Cách xử lý: copy, hoặc Immer.
  • Triệu chứng: child push vào array prop; parent không bao giờ update. Nguyên nhân: mutate props. Cách xử lý: onChange với array mới.
  • Triệu chứng: props copy vào state bị cũ. Nguyên nhân: derived-state + effect sync. Cách xử lý: derive lúc render.
  • Triệu chứng: dashboard giật vì một input. Nguyên nhân: state lift quá cao. Cách xử lý: đưa draft xuống, hoặc selector của store.

Câu người phỏng vấn hay hỏi

Child làm items.push(row) như một bạn Vue mutate list reactive. Parent vẫn cũ, setUser(user) sau user.name = 'x' không có tác dụng, và đưa draft lên root của trang thì dashboard re-render mỗi lần gõ phím. Ai chịu trách nhiệm data, và vì sao mutation lại là bug React?

Cách trả lời khi phỏng vấn

Props là contract của parent. Mình không mutate chúng. setState của React bail out khi cùng reference, nên user.name = x; setUser(user) không làm gì; Proxy của Vue thì đã thấy lần ghi đó. Mình copy khi update, hoặc dùng reducer / Immer khi team lớn. Child gọi onChange. Chúng không chịu trách nhiệm props. Nếu một input leaf làm dashboard re-render, state đang sai chỗ. Mình lift chỉ cao bằng những chỗ đọc dùng chung. Derive lúc render; copy props kèm effect sync thường là state sai.

Nhớ nhanh

  • setState + cùng reference không làm gì. Copy hoặc dùng Immer.
  • Child gọi onChange. Chúng không chịu trách nhiệm props.
  • Suy ra lúc render. Copy props rồi sync bằng effect thường là state sai.
  • Proxy của Vue thấy mutation tại chỗ; React thì không.

Câu hỏi nối tiếp thường gặp

  • Controlled vs uncontrolled? Xem 20.1.5.
  • Single source of truth khi URL, server, và local đều muốn field? Chọn một owner. Xem doc state-management của React.
  • Khi nào clone root của bảng 10k row mới là bug? Mỗi lần sửa một ô là copy 10k row. Lưu theo id hoặc share cấu trúc.
  • Vì sao config={{}} làm gãy memo child? Object identity mới mỗi lần render, nên memo thấy props đổi.

Tóm tắt: Input controlled nghĩa là state React là nguồn sự thật (value + onChange). Uncontrolled nghĩa là DOM mới là nguồn (defaultValue + ref / FormData). Input file uncontrolled vì platform, không phải vì bạn thích.

Vì sao quan trọng: Form 80 field giật mỗi lần gõ, input file “cannot be controlled”, và value={maybeUndefined} sau thành string thì warn uncontrolled→controlled. Họ muốn lúc nào React chịu trách nhiệm value, lúc nào là DOM, và bạn có kéo React Hook Form ra không.

  1. Cách hoạt động: Controlled: mỗi lần gõ gọi setState, React re-render, và value={name} là thứ DOM hiện. Thiếu onChange thì field đứng im. Uncontrolled: defaultValue gán sẵn lên DOM; lúc submit bạn đọc qua ref hoặc FormData.
  2. Bạn không được flip field từ uncontrolled sang controlled (hoặc ngược lại) giữa các lần render. value={undefined} rồi thành string là warning kinh điển. Input file: đọc e.target.files, đừng bao giờ value={file}.
  3. Tương đương Vue: v-model là đường tắt prop + emit. React không có đường tắt: value kèm onChange, hoặc để DOM lo. defineModel vẫn là cặp đó.
// Đứng im: value mà không có onChange
<input value={name} />

// Warning flip nếu name bắt đầu undefined, rồi thành string
<input value={name} onChange={(e) => setName(e.target.value)} />

// File: platform-uncontrolled, đọc FileList
<input type="file" onChange={(e) => setFiles(e.target.files)} />

// Map controlled tương đương v-model="name"
<input value={name} onChange={(e) => setName(e.target.value)} />

Điều gì xảy ra: Input đầu không nhúc nhích vì React chịu trách nhiệm value mà không ai ghi. name bắt đầu là undefined thì field uncontrolled, rồi thành string thì thành controlled. React sẽ warn. Input file chỉ có onChange. Dòng cuối là cặp v-model.

CáchDùng khiChi phí
ControlledCần mọi lần gõ trong ReactRe-render mỗi phím
UncontrolledĐọc lúc submit; widget nativeRẻ; khó validate live hơn
RHF / tương tựForm trong app thậtUncontrolled mặc định; Controller ở leaf
Input fileLuônPlatform-uncontrolled

Góc nhìn senior

  • Mặc định senior cho form thật: React Hook Form (hoặc tương tự), validate bằng schema, uncontrolled chỗ design system cho phép, Controller chỉ ở leaf.
  • Đừng tự viết lại RHF bằng 40 useState. Đừng nhét draft 80 field vào Context. Mỗi lần gõ sẽ re-render cả cây.
  • Nếu RHF/register (uncontrolled) hết giật, vấn đề là React đang chịu trách nhiệm từng phím, không phải “React chậm”.
  • Debounce value của input thì cảm giác gãy (caret bị trễ). Debounce payload gửi server; giữ input chạy live.
  • shadcn/MUI TextField phải đi qua Controller vì chúng là controlled component.

Lỗi hay gặp

  • Triệu chứng: field đứng im. Nguyên nhân: value mà không có onChange. Cách xử lý: thêm onChange, hoặc chuyển sang defaultValue.
  • Triệu chứng: warning uncontrolled→controlled. Nguyên nhân: value={undefined} rồi thành string. Cách xử lý: value={name ?? ''} từ render đầu tiên.
  • Triệu chứng: form giật 60 phím/s. Nguyên nhân: 40 useState ở root của trang. Cách xử lý: RHF, hoặc tách field ra.
  • Triệu chứng: input file “cannot be controlled”. Nguyên nhân: value={file}. Cách xử lý: đọc FileList; reset bằng inputRef.current.value = ''.

Người phỏng vấn hỏi gì

Form 80 field giật mỗi lần gõ, input file “cannot be controlled”, và value={maybeUndefined} sau thành string thì warn uncontrolled→controlled. Lúc nào state React là nguồn sự thật, lúc nào là DOM, và bạn kéo React Hook Form hay 40 useState?

Trả lời khi phỏng vấn

v-model là đường tắt; React là value kèm onChange, hoặc DOM nếu mình để uncontrolled. Input file uncontrolled vì platform. Mình đọc e.target.files, không bao giờ gán value={file}. Mình không flip field từ undefined sang string. Form 80 field mình dùng React Hook Form, uncontrolled chỗ design system cho phép, Controller chỉ ở leaf. Mình không tự viết lại bằng 40 useState. Form giật thì mình mở Profiler: vấn đề thường là React đang chịu trách nhiệm từng phím, không phải React chậm.

Nhớ nhanh

  • Controlled = React chịu trách nhiệm value + onChange. Uncontrolled = DOM chịu trách nhiệm.
  • Đừng flip uncontrolled ↔ controlled giữa các lần render.
  • Input file là platform-uncontrolled. Đọc FileList.
  • Form production: RHF (hoặc tương tự), không phải 40 useState. v-model là cặp này.

Câu hỏi nối tiếp thường gặp

  • Gắn TextField shadcn/MUI thế nào? Nó là controlled. Bọc trong Controller của RHF.
  • Lỗi validate server khác với chỉ Zod phía client? Map lỗi server lên field. Zod là cổng đầu, không phải cổng duy nhất.
  • Vì sao debounce value cảm giác gãy? Caret bị trễ. Debounce request, đừng debounce value controlled.
  • Draft trong Zustand thay vì RHF + URL? Wizard nhiều route. Form một trang thì để trong RHF.

Tóm tắt: key là identity xuyên các lần render, cùng contract với :key của Vue. Key theo index tái sử dụng nhầm state local khi list đổi thứ tự. Đó là bug input nhảy sang row khác.

Vì sao quan trọng: List todo reorder được dùng key={index}. Sau sort, input uncontrolled hiện text của row sai. map trả <>…</> thì warn, rồi có người “sửa” state cũ bằng key={Math.random()}.

  1. Cách hoạt động: Ưu tiên id nghiệp vụ ổn định. Key theo index chỉ chấp nhận cho list tĩnh, không insert, xóa, filter, hay reorder. Key nằm trên element trong array, không phải prop thường mà child đọc.
  2. Thiếu key → React fallback sang index bên trong rồi warn. Hai child trong map cần <Fragment key={id}> (<> ngắn không nhận key). Reorder với key index: input uncontrolled dính theo vị trí DOM.
  3. key={user.id} trên wizard cố ý remount và xóa state local: đó là API reset hợp lệ. key={Math.random()} remount mỗi lần render và là bug.
  4. Tương đương Vue: :key là cùng contract identity. Vue cũng tái sử dụng nhầm instance component khi bạn gắn key v-for theo index rồi sort.
// Key index + reorder → state input đi theo index của row
{todos.map((t, i) => (
  <li key={i}>
    <input defaultValue={t.title} />
  </li>
))}

// Id ổn định. Hai sibling cần Fragment key; <> ngắn thì không
{items.map((item) => (
  <Fragment key={item.id}>
    <dt>{item.term}</dt>
    <dd>{item.def}</dd>
  </Fragment>
))}

Điều gì xảy ra: Sau sort, React tái sử dụng fiber <li key={0}> ở vị trí 0. Value DOM của input uncontrolled giữ nguyên; text title xung quanh đổi. Thành ra sai row. Bản Fragment giữ cặp <dt>/<dd> theo item.id.

Chọn keyKhi nàoLỗi
Id nghiệp vụMặc địnhHiếm (id trùng)
IndexChỉ list tĩnhReorder → sai state local
Math.random()Không bao giờRemount mỗi lần render
Remount bằng keyReset có chủ đíchKhông ghi chú, trông như bug

Góc nhìn senior

  • Để <input> uncontrolled trong mỗi row, reorder, xem text nào ở lại. Demo đó chính là câu phỏng vấn.
  • Đổi key thì useEffect chạy cleanup + setup. Remount là fiber mới. Ghi rõ key={user.id} trên wizard là một reset.
  • Key trùng là undefined behaviour. Cửa sổ virtualized với key={startIndex + i} tái sử dụng nhầm row khi scroll.
  • id từ undefined sang 123 trên form sẽ remount và có thể hủy submit đang chạy. Gán sẵn client id ổn định trước.
  • Reconciliation type + key từ 20.1.1 vẫn đúng: sort với highlight DevTools cho thấy update khác mount.

Lỗi hay gặp

  • Triệu chứng: input hiện sai row sau sort. Nguyên nhân: key={index} + state local/uncontrolled. Cách xử lý: key={todo.id}.
  • Triệu chứng: warn list về key. Nguyên nhân: thiếu key, hoặc <> ngắn trong map. Cách xử lý: <Fragment key>.
  • Triệu chứng: form reset mỗi lần gõ. Nguyên nhân: key={Math.random()} hoặc key là object mới. Cách xử lý: id ổn định.
  • Triệu chứng: wizard không reset. Nguyên nhân: vẫn cùng key={user.id} trong khi field phải xóa. Cách xử lý: đổi key có chủ đích, và nói rõ.

Người phỏng vấn hỏi gì

List todo reorder được dùng key={index}. Sau sort, input uncontrolled hiện text của row sai. map trả <>…</> thì warn, rồi có người “sửa” state cũ bằng key={Math.random()}. Key để làm gì, và khi nào remount là chủ đích?

Trả lời khi phỏng vấn

key là identity, cùng contract với :key của Vue. Index chỉ ổn với list tĩnh. List reorder được thì key theo index khiến input uncontrolled dính vị trí DOM, đúng cái bug sai row. Mình remount có chủ đích bằng key={user.id} trên wizard và ghi rõ điều đó; mình không dùng Math.random() để “sửa” state cũ. Hai sibling trong map thì cần Fragment key; fragment ngắn thì không. Đổi key chạy cleanup và setup effect vì đó là fiber mới.

Nhớ nhanh

  • Id nghiệp vụ ổn định. Index chỉ khi list không thể đổi chỗ.
  • Key index + reorder = state local dính vị trí DOM.
  • Remount bằng key là API reset, không phải cách ép state cũ.
  • :key của Vue là cùng identity contract.

Câu hỏi nối tiếp thường gặp

  • Vì sao đổi key chạy useEffect cleanup + setup? Remount là fiber mới. Effect gắn vào fiber đó.
  • Đi qua một lần sort với highlight DevTools? Id ổn định thì thấy update; key theo index thì thấy mount. Xem 20.1.1.
  • Cửa sổ virtualize: vì sao key={startIndex + i} là cái bẫy? Scroll dùng lại state local của sai row, giống lúc reorder.
  • Khi nào id từ undefined sang 123 hủy một submit? Key đổi, nên form remount giữa request.

Tóm tắt: useEffect đồng bộ React với hệ thống bên ngoài sau paint: subscription, DOM API, widget third-party. Nó không phải onMounted, không phải computed, cũng không phải API fetch mặc định. Strict Mode gọi setup+cleanup hai lần lúc dev.

Vì sao quan trọng: Đồng đội quen Vue map onMounted + watch(id, fetch) sang useEffect. Strict Mode chạy hai lần, fetch cũ thắng race, rồi useEffect(() => setX(transform(y)), [y]) vẽ một frame UI sai. Khi nào cần effect, và data nên load ở đâu?

  1. Cách hoạt động: Effect chạy sau paint. Mảng dependency quyết định lúc nào setup chạy lại. [] là “sau mount”, kèm remount Strict Mode lúc dev, không phải “một lần trong đời tab”. Cleanup phải hoàn tác setup (idempotent).
  2. Không thế giới ngoài → không effect. Giá trị suy ra thuộc lúc render. Logic sự kiện user thuộc handler. Load data thuộc TanStack Query, loader của framework, hoặc fetch RSC, xem 20.1.12.
  3. Bạn có thể không cần effect: useEffect(() => setX(transform(y)), [y]) là derived state. Tính transform(y) lúc render.
  4. Tương đương Vue: watch / onMounted không phải useEffect. watch Vue có thể flush trước paint; effect luôn chạy sau. watchEffect tự track; dep của useEffect liệt kê bằng tay.
// Đồng bộ hệ thống ngoài. Cleanup bắt buộc: Strict Mode remount.
useEffect(() => {
  const el = ref.current
  if (!el) return
  const ro = new ResizeObserver(onSize)
  ro.observe(el)
  return () => ro.disconnect() // phải hoàn tác setup
}, [])

// Có thể không cần effect: suy ra lúc render
// useEffect(() => setTotal(items.reduce(...)), [items])
const total = items.reduce((s, it) => s + it.price, 0)

Điều gì xảy ra: Observer gắn sau paint và gỡ lúc unmount. Strict Mode lúc dev chạy cặp đó hai lần: thiếu cleanup thì leak. total suy ra lúc render, nên paint đầu đã đúng. Fetch trong effect này sẽ race nếu không có AbortController.

CầnĐể ở
Giá trị suy raRender (const x = f(y))
User click / submitEvent handler
Subscribe / widget DOMuseEffect + cleanup
Load document / listRSC / Query / loader, không useEffect

Góc nhìn senior

  • Effect làm Hooks đủ bộ. Chúng cũng là nguồn bug production số 1 ở codebase React của người quen Vue, vì map onMounted + watch quá sát chữ.
  • “Effect chạy hai lần” trong Strict Mode là chuyện bình thường. GET trùng trên tab Network lúc dev khác prod chính là dấu hiệu.
  • Race: A chậm, B nhanh, A thắng, không abort. Ưu tiên queryKey + cancel của Query, hoặc AbortController nếu bạn chịu trách nhiệm effect đó.
  • useEffectEvent (React 19) giữ callback mới nhất mà không subscribe lại. Không phải cách skip exhaustive-deps tràn lan.
  • Fetch mặc định là Query hoặc RSC, không phải useEffect. Mình nói thẳng điều đó lúc phỏng vấn.

Lỗi hay gặp

  • Triệu chứng: một frame UI sai, rồi nhảy. Nguyên nhân: useEffect(() => setX(f(y)), [y]). Cách xử lý: suy ra f(y) lúc render.
  • Triệu chứng: fetch hai lần lúc dev. Nguyên nhân: remount Strict Mode + fetch trong useEffect. Cách xử lý: Query, hoặc chấp nhận lúc dev và cleanup.
  • Triệu chứng: response cũ đè response mới. Nguyên nhân: không abort. Cách xử lý: AbortController hoặc cancel của Query.
  • Triệu chứng: websocket leak. Nguyên nhân: thiếu cleanup. Cách xử lý: return unsubscribe; Strict Mode sẽ chứng minh.

Nhà tuyển dụng hỏi gì

Đồng đội quen Vue map onMounted + watch(id, fetch) sang useEffect. Strict Mode chạy hai lần, fetch cũ thắng race, rồi useEffect(() => setX(transform(y)), [y]) vẽ một frame UI sai. Khi nào cần effect, và data nên load ở đâu?

Trả lời khi phỏng vấn

useEffect để đồng bộ thế giới ngoài sau paint: subscription, DOM API, widget third-party. Nó không phải onMounted và không phải computed. Data suy ra mình tính lúc render; ý định user mình để trong handler; data mình load bằng Query hoặc RSC. Strict Mode gọi hai lần là máy dò leak: cleanup phải hoàn tác setup. Có thể không cần effect: setState từ transform(y) là derived state. watch Vue có thể flush trước paint; effect luôn chạy sau. Fetch trong useEffect là fallback của SPA nhỏ, không phải mặc định của Next.

Nhớ nhanh

  • Không thế giới ngoài → không effect. Suy ra lúc render.
  • [] không phải “một lần mỗi tab”. Strict Mode remount lúc dev.
  • Fetch mặc định là Query hoặc RSC, không phải useEffect.
  • watch / onMounted Vue không phải useEffect.

Câu hỏi nối tiếp thường gặp

  • Vì sao Strict Mode chạy effect hai lần? Remount lúc dev để chứng minh cleanup. Prod chạy một lần trừ khi dep đổi.
  • Fetch theo id nên để ở đâu? queryKey của Query hoặc fetch RSC, không phải useEffect([id]). Xem 20.1.12.
  • useLayoutEffect thay thế? Chỉ để đo hoặc cắt nháy trước paint. Xem 20.1.11.
  • useEffectEvent khác eslint-disable? Props mới nhất trong subscription kéo dài. Không phải skip luật dep.

Tóm tắt: Suy ra lúc render trước (const total = items.reduce(...)). useMemo là cache opt-in, dep bạn tự liệt kê, không phải computed của Vue. React Compiler, khi bật trong app React 19, đã memo nhiều chỗ như vậy.

Vì sao quan trọng: Đồng đội bọc mọi hằng trong useMemo “như computed”, child memo vẫn re-render vì dep là [] mới mỗi lần, và team vừa bật React Compiler. Có nên useMemo cái này, nó có phải computed, và Compiler đổi gì?

  1. Cách hoạt động: Suy ra lúc render trước. Thêm useMemo cho (1) việc đã chứng minh là đắt hoặc (2) ổn định tham chiếu của object/array truyền vào child memo hoặc liệt kê trong dep effect.
  2. Sai dep → memo stale. Dep là object mới mỗi lần → memo không bao giờ hit. useMemo là cache, không bảo đảm việc bị skip khi concurrent render.
  3. Tương đương Vue: computed Vue là lazy và tự track. useMemo là cache, dep bạn tự liệt kê. Đừng dịch mọi computed thành useMemo.
// Suy ra lúc render, không cần useMemo
const total = items.reduce((s, it) => s + it.price, 0)

// Identity contract cho memo child / dep effect
const ids = useMemo(() => items.map((it) => it.id), [items])

// Không bao giờ hit: [] mới mỗi render là identity dep mới
const bad = useMemo(() => filter(items, opts), [{ sort }])

Điều gì xảy ra: total rẻ và đúng ngay lúc render. ids giữ cùng identity array khi items không đổi, nên child memo bail out được. { sort } làm dep là object mới mỗi lần, nên bad tính lại luôn: memo chỉ là hình thức.

Vue computedReact useMemo
TrackingTự động, lazyDep bạn liệt kê
Mặc địnhCó, cho giá trị suy raKhông, suy ra lúc render trước
CompilerReactivity đã trackReact Compiler có thể memo giúp bạn

Góc nhìn senior

  • Memo tay ghi “identity này quan trọng”. Lạm dụng là dấu junior. Thiếu đến mức gãy child đã memo ([] mới mỗi lần) là bug thật: sửa identity ở đó.
  • Profile parent trước khi thêm useMemo. Nếu Compiler đang bật, xem output đã compile / Profiler lại sau khi xóa memo.
  • useMemo không làm map rẻ chạy nhanh hơn. Nó làm identity ổn định. Đó là hai việc khác nhau.
  • React Compiler (opt-in, React 19) đã memo nhiều chỗ. Chồng memo tay có thể đánh nhau với nó.
  • Object options mới trong list dep là miss hay gặp. Depend sort, không phải { sort }.

Lỗi hay gặp

  • Triệu chứng: mọi hằng bọc useMemo. Nguyên nhân: coi nó như computed. Cách xử lý: suy ra lúc render; memo chỉ khi chi phí đã chứng minh hoặc identity.
  • Triệu chứng: child memo vẫn re-render. Nguyên nhân: dep là [] hoặc {} mới. Cách xử lý: memo giá trị đó, hoặc truyền primitive.
  • Triệu chứng: filter cũ sau khi opts đổi. Nguyên nhân: thiếu dep. Cách xử lý: liệt kê field opts, không object mới.
  • Triệu chứng: Compiler + memo tay đánh nhau. Nguyên nhân: policy “bọc hết”. Cách xử lý: đo, rồi xóa.

Nhà tuyển dụng hỏi gì

Đồng đội bọc mọi hằng trong useMemo “như computed”, child memo vẫn re-render vì dep là [] mới mỗi lần, và team vừa bật React Compiler. Có nên useMemo cái này, nó có phải computed, và Compiler đổi gì?

Trả lời khi phỏng vấn

Mình suy ra lúc render trước. useMemo không phải computed: computed Vue là lazy và tự track; useMemo là cache mình tự liệt kê dep. Mình thêm khi Profiler hiện việc đắt, hoặc khi identity là contract cho memo child hay effect. Nếu Compiler đang bật, chồng memo tay thường chỉ là nhiễu. Array hoặc object mới làm dep nghĩa là memo không bao giờ hit, đó là lý do memo child vẫn re-render. Mình Profile parent trước khi thêm useMemo, rồi lại sau khi xóa nó.

Nhớ nhanh

  • Render trước. useMemo không phải computed.
  • Dùng khi chi phí đã chứng minh hoặc identity là contract.
  • Compiler đã memo nhiều chỗ: đo trước khi chồng.
  • [] / {} mới làm dep nghĩa là memo không bao giờ hit.

Câu hỏi nối tiếp thường gặp

  • useMemo có bảo đảm dưới concurrent React? Nó chỉ là gợi ý cache. Phép tính vẫn phải thuần, dù có memo hay không.
  • Khi nào identity mới là lý do, không phải chi phí? Child đã memo, hoặc dep của effect mà không nên chạy lại.
  • React Compiler đổi gì? Nhiều memo viết tay thành thừa. Đo, rồi xóa.
  • computed Vue có setter? Vẫn là writable ref. Bên React thì là useState kèm lần đọc giá trị suy ra.

Tóm tắt: useCallback giữ identity của hàm ổn định khi identity đó là contract: memo child, dep của effect, value của context. Nó không làm thân hàm rẻ hơn. Vue hiếm khi cần vì child track data, không track identity onClick của parent.

Vì sao quan trọng: Mọi handler đều bọc useCallback “cho perf”, row đã memo vẫn re-render vì onClick là hàm mới, rồi người chuyên Vue hỏi sao Vue không bao giờ cần cái này. Vì sao identity hàm quan trọng trong React, và lúc nào useCallback chỉ là lãng phí?

  1. Cách hoạt động: useCallback(fn, deps) trả cùng function reference khi deps vẫn bằng nhau theo Object.is. Hàm mới mỗi lần render làm memo fail (prop đổi) và effect nào liệt kê callback sẽ chạy lại.
  2. Dep không ổn định (useCallback(() => do(x), []) với x cũ) là ship stale closure. Liệt kê setState thì ổn (React bảo đảm identity của nó). Liệt kê state khi bạn muốn liệt kê setState thì callback bị tạo lại mỗi lần.
  3. Tương đương Vue: Child Vue tự track dep của chúng, nên identity onClick của parent hiếm khi quan trọng. memo của React so sánh function reference.
const Row = memo(function Row({ item, onSelect }: Props) {
  return <button onClick={() => onSelect(item.id)}>{item.name}</button>
})

function List({ items }: { items: Item[] }) {
  // ổn định khi items không đổi: Row bail out được
  const onSelect = useCallback((id: string) => {
    save(id)
  }, [])
  return items.map((item) => <Row key={item.id} item={item} onSelect={onSelect} />)
}

Điều gì xảy ra: Không có useCallback, onSelect là hàm mới mỗi lần List render, nên mọi Row đều fail memo dù item không đổi. Có nó thì identity giữ nguyên. Nếu save cần filter đang đổi, giá trị đó phải nằm trong list dep, không thì stale closure bị ship.

Identity quan trọngBỏ useCallback
Prop của child memo<button> native, không memo
Dep useEffectHandler chỉ dùng trong JSX
Value của contextCompiler đã memo sẵn
Register bên third-partyPolicy “bọc mọi handler”

Góc nhìn senior

  • React Compiler thường tự memo callback. Team đã bật Compiler: đừng rải useCallback theo policy; giữ handler trong scope event; chỉ thêm khi Profiler hoặc lint rule chỉ ra một contract.
  • Profiler child đã memo, không phải parent. Nếu child là <button> native, bỏ useCallback trừ khi thứ khác phụ thuộc identity.
  • useEvent / useEffectEvent là công cụ React 19 để lấy callback mới nhất trong effect mà không liệt kê nó như dep.
  • Truyền onSelect qua Context sẽ re-render mọi consumer khi identity đổi. Ổn định identity, hoặc tách context.
  • Vue không cần cái này vì child subscribe data, không subscribe reference hàm của parent.

Lỗi hay gặp

  • Triệu chứng: bọc mọi handler, chẳng nhanh hơn. Nguyên nhân: identity không phải contract. Cách xử lý: xóa đi; đo child đã memo.
  • Triệu chứng: row memo vẫn re-render. Nguyên nhân: props khác (style={{}}) hoặc callback bắt state đang đổi trong closure với []. Cách xử lý: sửa deps, hoặc thôi bọc.
  • Triệu chứng: filter cũ trong onSelect. Nguyên nhân: useCallback(..., []). Cách xử lý: liệt kê filter, hoặc đọc từ ref.
  • Triệu chứng: Compiler + useCallback viết tay khắp nơi. Nguyên nhân: policy. Cách xử lý: chỉ giữ ở các lá memo.

Nhà tuyển dụng hay hỏi gì

Mọi handler đều bọc useCallback “cho perf”, row đã memo vẫn re-render vì onClick là hàm mới, rồi người chuyên Vue hỏi sao Vue không bao giờ cần cái này. Vì sao identity hàm quan trọng trong React, và lúc nào useCallback chỉ là lãng phí?

Trả lời khi phỏng vấn

useCallback không làm thân hàm rẻ hơn. Nó giữ identity ổn định khi identity đó là contract cho memo child, dep của effect, hoặc value của context. Vue không cần vì child track data, không track identity onClick của parent. Mình Profiler child đã memo. Nếu child là button native thì mình bỏ. Dep rỗng mà filter đang đổi là ship stale closure. Team dùng React Compiler thì mình không rải useCallback theo policy; mình chỉ thêm khi Profiler cho thấy có contract.

Nhớ nhanh

  • Identity là contract, không phải JS rẻ hơn.
  • Vue track data. memo của React track function reference.
  • Team dùng Compiler: đừng rải. Trước Compiler: chỉ ở biên các lá memo.
  • useCallback(..., []) với data đang đổi là stale closure.

Câu hỏi nối tiếp thường gặp

  • useCallback có làm thân handler nhanh hơn? Không. Nó chỉ ổn định reference.
  • Dùng useEffectEvent? Props mới nhất trong effect mà không nên subscribe lại. Việc khác.
  • Context + callback? Identity không ổn định thì re-render mọi consumer. Ổn định, hoặc tách.
  • Inline onClick={() => …} lúc nào ổn? Child không memo, và không ai liệt kê hàm đó như dep.

Tóm tắt: useRef là hộp mutable { current } sống qua các lần render và không báo React. Nó không phải ref() của Vue. Template ref Vue (DOM / instance component) mới là thứ gần nhất. React 19 thì ref là prop bình thường, nên forwardRef thường không cần.

Vì sao quan trọng: Người chuyên Vue viết const count = useRef(0) như viết ref(0), rồi thắc mắc sao UI đứng im. Chỗ khác họ đọc ref.current lúc render để rẽ nhánh, và muốn lấy instance child như template ref.

  1. Cách hoạt động: useRef(init) trả { current: init }. Gán .current không schedule một render. Đọc và ghi ref trong effect và handler, không lấy chúng làm nguồn output đã render.
  2. DOM ref: ref={el} trên host node. React 19 thì function component nhận ref như prop; forwardRef vẫn cần cho cây cũ và một số library. useImperativeHandle expose một API instance nhỏ.
  3. Tương đương Vue: ref() của Vue là state reactive → useState của React. Template ref Vue → useRef của React. Dịch ref(0) thành useRef(0) là cách UI đứng im.
function Field({ label }: { label: string }) {
  const inputRef = useRef<HTMLInputElement>(null)
  const count = useRef(0) // hộp: UI SẼ KHÔNG update
  const [n, setN] = useState(0) // state: UI sẽ update

  function focus() {
    inputRef.current?.focus() // handler: OK
    count.current += 1
  }
  // if (inputRef.current) ... lúc render là bẫy concurrent
  return <input ref={inputRef} aria-label={label} onFocus={focus} value={n} />
}

Điều gì xảy ra: Bấm focus thì caret chạy tới và count.current tăng, nhưng màn hình vẫn hiện n vì chỉ useState mới báo React. ref={inputRef} chính là tương đương template ref. Rẽ nhánh theo inputRef.current lúc render là race khi React concurrent.

VueReact
ref(0) (reactive)useState(0)
Template ref / useTemplateRefuseRef + ref={…}
defineExposeuseImperativeHandle
Luôn là binding kiểu propReact 19: ref là prop

Góc nhìn senior

  • Nếu tăng .current mà UI không paint, API đang chạy đúng. Nếu user phải thấy con số, bạn cần useState.
  • Với “latest props trong subscription ổn định”, ref kèm useEffectEvent hơn hẳn việc nhét callback vào list dep.
  • useImperativeHandle là chọc vào child: hợp lệ cho video player / focus manager; đáng ngờ nếu data lẽ ra phải là props.
  • Callback ref khác object ref: callback chạy lúc node gắn/gỡ. Object ref chỉ nằm đó. Ưu tiên object ref trừ khi bạn cần tín hiệu attach.
  • Đừng đọc hoặc ghi ref trong thân render khi React concurrent. Render có thể bị bỏ.

Lỗi hay gặp

  • Triệu chứng: UI đứng ở 0 sau count.current++. Nguyên nhân: dùng useRef như ref() của Vue. Cách xử lý: useState.
  • Triệu chứng: hydration / tearing từ if (ref.current) lúc render. Nguyên nhân: render bị bỏ đã thấy node khác. Cách xử lý: đọc trong effect.
  • Triệu chứng: child bỏ qua ref. Nguyên nhân: function component trước 19 không có forwardRef. Cách xử lý: nhận ref như prop (React 19) hoặc bọc.
  • Triệu chứng: useImperativeHandle đổ cả store. Nguyên nhân: data biến thành API instance. Cách xử lý: props.

Nhà tuyển dụng hay hỏi gì

Người chuyên Vue viết const count = useRef(0) như viết ref(0), rồi thắc mắc sao UI đứng im. Chỗ khác họ đọc ref.current lúc render để rẽ nhánh, và muốn instance child như template ref. useRef là gì, và khi nào phải dùng state?

Trả lời khi phỏng vấn

useRef là hộp mutable, không báo React. Vue ref() là state reactive. Dịch vậy là UI đứng im. Mình dùng useState khi user phải thấy thay đổi, và useRef cho node, timer id, AbortController, hoặc callback mới nhất trong subscription ổn định. React 19 thì ref là prop, nên mình hiếm khi viết forwardRef. Mình không đọc ref.current lúc render khi React concurrent. Template ref Vue là tương đương DOM, không phải ref().

Nhớ nhanh

  • Vue ref() → useState. Vue template ref → useRef.
  • .current = x không re-render. Đó là mục đích.
  • Đừng đọc/ghi ref trong thân render khi React concurrent.
  • React 19: ref là prop. forwardRef thường không cần.

Câu hỏi nối tiếp thường gặp

  • Timer ids và AbortController? useRef. Chúng là field của instance, không phải UI.
  • Callback mới nhất trong subscription? Một ref, hoặc useEffectEvent trong React 19.
  • useImperativeHandle vs props? Player và focus. Data vẫn là props.
  • Callback ref vs object ref? Callback chạy lúc gắn/gỡ. Object ref là hộp. Ưu tiên hộp.

Tóm tắt: Nghĩ theo pha, không theo tên method của class. Render thì thuần. Commit áp DOM. Layout effects (useLayoutEffect) chạy trước paint. Passive effects (useEffect) chạy sau paint. Mount / update / unmount là lúc các effect đó chạy.

Vì sao quan trọng: Họ bảo bạn map componentDidMount sang Hooks, rồi tooltip nháy, useLayoutEffect + setHeight bị loop, và trang Next hydrate sai vì render gọi Date.now(). Vì sao render phải thuần, và khi nào useLayoutEffect mới là pha đúng?

  1. Cách hoạt động: Render (thuần): nhận props/state/context, trả JSX. Không network, không setState, không ghi DOM. Concurrent React có thể render hai lần hoặc bỏ một render. Tính thuần là thứ giữ cho việc đó an toàn.
  2. Commit áp mutation DOM. Rồi useLayoutEffect chạy trước khi browser paint: đo, hoặc diệt nháy. Rồi browser paint. Rồi useEffect chạy cho thế giới bên ngoài.
  3. Unmount chạy cleanup cuối. Strict Mode lúc development remount một lần để chắc cleanup đúng.
  4. Tương đương Vue: Vue đặt tên theo hook (onMounted, onUpdated, onBeforeUpdate). React đặt tên theo pha. onBeforeUpdate gần useLayoutEffect hơn; onMounted gần useEffect, chứ không phải tương đương.
function Tooltip({ text }: { text: string }) {
  const ref = useRef<HTMLDivElement>(null)
  const [h, setH] = useState(0)
  // layout: đo trước paint để tránh nháy
  useLayoutEffect(() => {
    const next = ref.current?.getBoundingClientRect().height ?? 0
    if (next !== h) setH(next) // guard: height trong deps, không có so sánh này thì loop
  }, [text, h])
  // render phải pure: không Date.now(), không Math.random()
  return <div ref={ref} style={{ top: h }}>{text}</div>
}

Điều gì xảy ra: Sau commit, layout effect đo node rồi, nếu cần, set height trước paint để tooltip không nháy ở top: 0. Guard next !== h chặn vòng layout→setState→layout. Bản thân render vẫn pure để hydration khớp.

PhaĐược phépKhông được
RenderĐọc props/state; trả JSXNetwork, setState, DOM, Date.now()
CommitReact ghi DOMCode của bạn (hầu như)
Layout effectsĐo; sync DOM trước paintViệc nặng (chặn INP)
Passive effectsSubscribe, fetch khi bắt buộcDerived setState (vẽ một frame sai)

Góc nhìn senior

  • Mình không bắt đầu từ tên method class. componentDidMount là “sau paint, có thể hai lần trong Strict Mode”, không phải hook chạy một lần cho cả tab.
  • useLayoutEffect chặn paint. Dùng nó để setState từ getBoundingClientRect dễ thrash, nhất là khi height nằm trong deps. Guard lần ghi.
  • Layout effect hết nháy (vị trí tooltip), cái giá là INP (Interaction to Next Paint: thời gian đến khi UI trông như đã phản hồi). Mặc định dùng useEffect.
  • Render không pure (Date.now(), Math.random(), window) hydrate sai trên Next. Để chúng trong effect, hoặc Server Component không hydrate path đó.
  • Strict Mode remount để chứng minh cleanup. Tooltip chỉ nháy lúc dev thường là thiếu layout effect, không phải bug Strict Mode.

Lỗi hay gặp

  • Triệu chứng: tooltip nháy ở 0 rồi nhảy. Nguyên nhân: đo trong useEffect (sau paint). Cách xử lý: useLayoutEffect kèm guard.
  • Triệu chứng: vòng layout vô hạn. Nguyên nhân: setHeight luôn ghi, height trong deps. Cách xử lý: chỉ ghi khi next !== h.
  • Triệu chứng: lệch hydration trên Next. Nguyên nhân: Date.now() / Math.random() lúc render. Cách xử lý: render pure; đọc thời gian trong effect.
  • Triệu chứng: INP tệ đi khi hover. Nguyên nhân: việc nặng trong useLayoutEffect. Cách xử lý: chỉ đo, tính sau.

Người phỏng vấn hỏi gì

Họ bảo bạn map componentDidMount sang Hooks, rồi tooltip nháy, useLayoutEffect + setHeight loop, và trang Next hydrate sai vì render gọi Date.now(). Vì sao render phải pure, và khi nào useLayoutEffect mới đúng pha?

Trả lời khi phỏng vấn

Mình không bắt đầu từ tên method class. Render phải pure: concurrent React có thể chạy hai lần hoặc bỏ đi. Mình commit, rồi useLayoutEffect trước paint chỉ để đo hoặc tránh nháy, rồi useEffect sau paint cho thế giới bên ngoài. Layout effect chặn paint và dễ thrash nếu setState luôn ghi. Date.now() lúc render thì hydrate sai. Vue đặt tên onMounted; React đặt tên theo pha. Mount, update, và unmount là các effect đó chạy, kèm cleanup.

Nhớ nhanh

  • Render phải pure. Concurrent React có thể render hai lần hoặc bỏ.
  • useLayoutEffect chặn paint. Mặc định dùng useEffect.
  • Render không pure (Date.now(), Math.random()) hydrate sai.
  • Vue đặt tên theo hook; React đặt tên theo pha. Chúng không map 1:1.

Câu hỏi nối tiếp thường gặp

  • Map componentDidMount? Sau paint, useEffect([]), và Strict Mode có thể remount lúc dev.
  • Layout thrashing: thấy thế nào? Panel Performance: forced reflow. Guard setState.
  • Vì sao height trong effect đo lại thành loop? Ghi → render → layout → ghi. So sánh trước khi ghi.
  • Date.now() chỉ SSR? Để trong Server Component, hoặc format sau mount với placeholder.

Tóm tắt: Chọn nguồn sự thật và runtime. Document, SEO, secret: fetch trong async Server Component. Cache live phía client, filter, mutation xuyên island: TanStack Query. Fetch trong useEffect là fallback của SPA nhỏ, không phải mặc định của Next.

Vì sao quan trọng: Trang Next App Router fetch document trong useEffect, island bên cạnh cần cùng list kèm filter, rồi sau mutation không ai invalidate. TanStack Query, RSC, hay effect viết tay: data nằm ở đâu, hủy request đang chạy thế nào?

  1. Cách hoạt động: fetch của RSC chạy trên server (bundle + secret không vào client) nhưng scoped theo request/cache, không phải cache live phía client. Waterfall xảy ra khi child chờ parent xong mới fetch được.
  2. Query là cache client (stale-while-revalidate, key, invalidation). Sau POST, invalidateQueries. Race: ưu tiên cancel của Query, hoặc AbortController nếu bạn chịu trách nhiệm effect đó.
  3. Tương đương Vue: useAsyncData / useFetch của Nuxt trong setup đóng vai trò giống RSC/payload. Pinia không phải HTTP cache. Cùng kiểu tách với Query khác Zustand.
// RSC: fetch document trên server (Next App Router)
export default async function Page({ params }: { params: { id: string } }) {
  const doc = await getDoc(params.id) // không useEffect
  return <Article doc={doc} />
}

// Island client: cache live + cancel qua Query
const { data } = useQuery({ queryKey: ['docs', filter], queryFn: fetchDocs })
// sau POST: queryClient.invalidateQueries({ queryKey: ['docs'] })

Điều gì xảy ra: HTML của document đã có doc, không cần fetch phía client. Island dùng cache Query theo key để filter và widget bên cạnh dùng chung row. Sau mutation bạn invalidate key đó, đừng lấy store.items.push làm nguồn sự thật. Fetch trong effect sẽ waterfall và race.

CầnỞ đâuKhông
SEO / secret / documentAsync Server ComponentuseEffect
Filter, polling, cache dùng chungTanStack QueryList Redux / Zustand
Vite SPA nhỏVẫn Query; effect là phương án cuốiGod-object store làm HTTP cache

Góc nhìn senior

  • RSC thắng first paint và size bundle; nó thua “typeahead chia cache với widget cách ba route” trừ khi thêm Query.
  • Query thắng UX cho data live phía client; cái giá là runtime client và phải học key. Cả hai, có luật, thì scale được.
  • List từ server không thuộc Redux / Zustand / Pinia. Cùng kiểu tách với doc state-management của Vue.
  • Waterfall: bắn các fetch độc lập cùng lúc (Promise.all trên server, hoặc key Query song song). Đừng chờ JSON của parent rồi mới fetch child.
  • Mình kiểm bằng waterfall trên Network, TTFB so với time-to-interactive, và cache hit trên Query Devtools. Race + AbortController chỉ khi mình chịu trách nhiệm effect đó.

Lỗi hay gặp

  • Triệu chứng: HTML trống, rồi mới fetch. Nguyên nhân: document load trong useEffect trên trang Next. Cách xử lý: async Server Component.
  • Triệu chứng: hai island refetch cùng một list. Nguyên nhân: không có cache dùng chung. Cách xử lý: queryKey của Query, không phải hai effect.
  • Triệu chứng: row còn stale sau PATCH. Nguyên nhân: không ai invalidate. Cách xử lý: invalidateQueries / revalidatePath.
  • Triệu chứng: response cũ thắng. Nguyên nhân: fetch trong effect không abort. Cách xử lý: Query, hoặc AbortController trong cleanup.

Người phỏng vấn hỏi gì

Trang Next App Router fetch document trong useEffect, island bên cạnh cần cùng list kèm filter, rồi sau mutation không ai invalidate. TanStack Query, RSC, hay effect viết tay: data nằm ở đâu, hủy request đang chạy thế nào?

Trả lời khi phỏng vấn

Mình chọn nguồn sự thật và runtime. Document, SEO, secret: async Server Component. Cache live phía client, filter, mutation dùng chung giữa các island: Query. Mình không fetch trong useEffect khi đã có Query hoặc RSC. Đó mới là lằn ranh senior. RSC theo phạm vi request, không phải cache live. Sau POST mình invalidateQueries. Waterfall bắt đầu khi child chờ parent; mình bắn các fetch độc lập cùng lúc. useAsyncData của Vue/Nuxt là analogue payload; Pinia không phải HTTP cache. AbortController chỉ khi mình chịu trách nhiệm effect đó.

Nhớ nhanh

  • RSC cho document. Query cho cache live phía client.
  • Fetch trong useEffect là fallback của SPA nhỏ, không phải mặc định của Next.
  • List từ server không thuộc Redux / Zustand / Pinia.
  • Sau POST thì invalidate. Hủy request đang chạy bằng Query hoặc AbortController.

Câu hỏi nối tiếp thường gặp

  • RSC khác Query cho dashboard 15 API? RSC cho document shell. Query cho widget live để chúng dùng chung cache và invalidate cùng lúc.
  • Vì sao Zustand là cache list sai? Không staleTime, không invalidation theo key, không có chuyện payload SSR. Giống Pinia-as-REST.
  • Tránh waterfall thế nào? Bắn các fetch độc lập cùng lúc. Đừng await JSON của parent rồi mới cho child chạy.
  • Tương đương Nuxt? useAsyncData / useFetch trong setup. Xem topic data-fetching của Nuxt.

Tóm tắt: Context là dependency injection cho giá trị ít đổi: theme, locale, user id, Query client. Đổi value của Provider thì re-render mọi consumer. memo trên consumer không skip được điều đó.

Vì sao quan trọng: App re-render mỗi lần gõ vì search nằm trong AppContext cạnh theme và user. Senior Vue nghĩ sẽ giống provide / inject, hỏi vì sao memo trên consumer không giúp, và Zustand có giúp không.

  1. Cách hoạt động: createContext + Provider value={…} + use(Context) (React 19) hoặc useContext. Identity của value đổi thì báo mọi consumer, kể cả những consumer chỉ đọc theme.
  2. Tách context (ThemeContext khác SearchContext) để một lần gõ không kéo chrome re-render. Hoặc để giá trị hay đổi trong Zustand / Jotai rồi subscribe theo selector.
  3. Tương đương Vue: provide / inject track ref được inject. Child không đọc search thì không chạy lại. Context không có đồ thị như vậy.
const ThemeContext = createContext<Theme>('light')
const SearchContext = createContext('') // tách: đừng dùng chung với theme

function App() {
  const [q, setQ] = useState('')
  return (
    <ThemeContext value="dark">
      <SearchContext value={q}>
        <input value={q} onChange={(e) => setQ(e.target.value)} />
        <Results />
      </SearchContext>
    </ThemeContext>
  )
}

Điều gì xảy ra: Gõ chỉ update SearchContext. Consumer của ThemeContext không re-render. Nếu q nằm trên một AppContext phình to, mọi use(AppContext) sẽ re-render, memo(Results) cũng không giúp.

ContextZustand / Jotai
Dùng choDI ít đổiUI client hay đổi
Chi phí updateMọi consumerSubscriber theo selector
memo giúp?Không, nếu consumer use contextCó, nếu slice được chọn ổn định
Tương đương Vueprovide / inject (nhưng Vue track)Pinia

Góc nhìn senior

  • Context không phải store cho từng lần gõ. Mình coi AppContext phình to là bug render cho đến khi chứng minh ngược lại.
  • Object mới value={{ theme, user }} mỗi render là broadcast dù field không đổi. Memo value hoặc tách.
  • Query client thuộc Context (ít đổi). Kết quả query thuộc Query, không phải Context.
  • Zustand khi nhiều reader ở xa cần một slice hay đổi. Context khi cây là ranh giới plugin.
  • React 19 use(Context) là API đọc. Nó cũng use được Promise dưới Suspense. Đó không phải store.

Lỗi hay gặp

  • Triệu chứng: chrome re-render mỗi lần gõ search. Nguyên nhân: search cùng Context với theme. Cách xử lý: tách, hoặc Zustand.
  • Triệu chứng: memo(Child) vẫn chạy. Nguyên nhân: Child gọi use(Context). Cách xử lý: đưa use vào wrapper nhỏ.
  • Triệu chứng: mọi consumer re-render dù theme không đổi. Nguyên nhân: value={{ theme, user }} identity mới. Cách xử lý: memo hoặc hai provider.
  • Triệu chứng: list user trong Context. Nguyên nhân: coi Context là cache. Cách xử lý: Query / RSC. Xem 20.2.7.

Người phỏng vấn hỏi gì

App re-render mỗi lần gõ vì search nằm trong AppContext cạnh theme và user. Senior Vue nghĩ sẽ giống provide / inject, hỏi vì sao memo trên consumer không giúp, và Zustand có giúp không. Context để làm gì, và khi nào nó là store bạn không nên có?

Trả lời khi phỏng vấn

Context là DI cho giá trị ít đổi: theme, locale, user id, query client. Không phải store cho từng lần gõ. Đổi value của Provider thì re-render mọi consumer, memo không skip được; provide của Vue thì track, nên inject không dùng không chạy lại. Mình tách ThemeContext khỏi SearchContext, hoặc để slice hay đổi trong Zustand. Object mới làm value là broadcast dù field bằng nhau. React 19 use(Context) là API đọc; mình vẫn tách provider.

Nhớ nhanh

  • Context là DI ít đổi. Đổi value thì re-render mọi consumer.
  • memo không skip consumer đã use(Context).
  • Tách context hoặc dùng Zustand cho slice hay đổi. provide của Vue thì track.
  • Đừng nhét list từ server hay từng lần gõ vào Context.

Câu hỏi nối tiếp thường gặp

  • Vì sao memo không giúp? Child vẫn use context, nên nó là consumer. Bọc use trong child nhỏ.
  • Zustand khác tách Context? Zustand khi nhiều reader ở xa cần selector. Tách Context khi cây là ranh giới plugin.
  • React 19 use(Context) khác useContext? Cùng lần đọc. use còn unwrap được Promise dưới Suspense.
  • Query client trong Context? Có: client ổn định. Kết quả ở lại Query.

Tóm tắt: createPortal(child, domNode) vẽ child vào một node DOM khác. Event vẫn bubble theo cây React, không phải cây DOM. Click-outside dựa DOM thì kỳ vọng điều ngược lại.

Vì sao quan trọng: Modal bị overflow: hidden cắt. Bạn portal ra document.body. Click-outside trên parent stopPropagation không thấy click, và trang Next hydrate lệch vì server trả null còn client thì portal.

  1. Cách hoạt động: Parent React giữ nguyên cho context, bắt lỗi, và bubble event. Parent DOM là domNode. CSS clipping/z-index theo parent DOM; stopPropagation trên DOM ancestor của trigger không thấy click từ portal.
  2. SSR: target portal phải tồn tại trên server, không thì bạn render null đến lúc mount. Lệch như vậy thì hydrate sai. Ưu tiên #modal-root có sẵn trong HTML.
  3. Tương đương Vue: <Teleport to="body"> cùng một ý tưởng product. Event Vue cũng theo cây Vue. Bẫy hydration vẫn vậy nếu server thiếu to.
function Modal({ children }: { children: React.ReactNode }) {
  const node = document.getElementById('modal-root')
  if (!node) return null // SSR: ưu tiên một node thật trong HTML
  return createPortal(
    <div role="dialog" className="modal">
      {children}
    </div>,
    node,
  )
}

Điều gì xảy ra: Dialog vẽ dưới #modal-root, thoát khỏi overflow: hidden. Click trên dialog vẫn bubble tới parent React của Modal, không tới DOM ancestor của #modal-root. Server trả null còn client trả dialog thì hydration lệch.

Vue <Teleport>React createPortal
VẽVào toVào domNode
EventCây VueCây React (không phải DOM)
ContextVẫn parent VueVẫn parent React
SSRTarget phải tồn tạiGiống; null khác portal thì lệch

Góc nhìn senior

  • Click-outside phải lắng trên document (hoặc node portal), không phải DOM ancestor của trigger. stopPropagation trên ancestor đó sẽ miss click.
  • Focus trap và aria-modal vẫn quan trọng. Portal chỉ là layout, không phải a11y.
  • Next: để #modal-root trong HTML layout gốc. Đừng document.body lúc paint đầu nếu server không render gì.
  • Portal lồng nhau vẫn bubble qua parent React. Đó là cách modal trong dropdown đóng cả hai, trừ khi bạn stop trong React.
  • disabled trên Teleport Vue không có one-liner; skip createPortal có điều kiện.

Lỗi hay gặp

  • Triệu chứng: modal bị cắt. Nguyên nhân: vẽ trong overflow: hidden. Cách xử lý: portal ra body / #modal-root.
  • Triệu chứng: click-outside không bao giờ chạy. Nguyên nhân: listener trên DOM ancestor; event đi lên cây React. Cách xử lý: lắng trên document.
  • Triệu chứng: lệch hydration Next. Nguyên nhân: server null, client portal. Cách xử lý: node target thật trong HTML SSR.
  • Triệu chứng: dropdown dưới modal bị đóng. Nguyên nhân: bubble React. Cách xử lý: stop trong handler React, không phải DOM.

Người phỏng vấn hỏi gì

Modal bị overflow: hidden cắt. Bạn createPortal ra document.body. Click-outside trên parent stopPropagation không thấy click, và trang Next hydrate lệch vì server trả null còn client thì portal. UI vẽ ở đâu, và event bubble ở đâu?

Trả lời khi phỏng vấn

createPortal vẽ vào node DOM khác nhưng parent React giữ nguyên, cùng ý với Teleport. Event vẫn bubble theo cây React, nên click-outside trên DOM ancestor không giống kiểu Teleport. Mình lắng trên document. Portal cần target thật trong HTML SSR; server trả null còn client trả dialog thì hydrate sai. Context và Error Boundary vẫn theo parent React. CSS overflow theo parent DOM, đó là lý do mình portal.

Nhớ nhanh

  • createPortal vẽ vào node DOM khác; event vẫn theo cây React.
  • Click-outside: lắng trên document, không phải DOM ancestor của trigger.
  • SSR: target portal phải tồn tại. null khác portal thì hydrate sai.
  • Vue <Teleport> cùng một ý, kể cả luật cây event.

Câu hỏi nối tiếp thường gặp

  • Context còn chạy qua portal? Có. Parent React không đổi.
  • Error Boundary? Vẫn parent React. Portal không thoát khỏi nó.
  • z-index war? Theo parent DOM. Vì vậy #modal-root nằm cuối body.
  • Vue disabled trên Teleport? Skip createPortal có điều kiện rồi render tại chỗ.

Tóm tắt: <Suspense fallback={…}> là boundary UI loading cho children suspend (throw một thenable): code-split bằng lazy(), và data (use(), RSC, một số mode Query). Nó không phải error handler.

Vì sao quan trọng: Widget lazy() không có boundary thì throw lúc runtime. Fetch data fail rồi trang treo spinner vì không có Error Boundary. Vue <Suspense> “cảm giác giống”. Suspense bắt gì, đặt ở đâu, và Vue có cùng nghĩa không?

  1. Cách hoạt động: Child suspend bằng cách throw một Promise. Suspense gần nhất hiện fallback đến khi Promise resolve, rồi retry cây. Cao quá → cả trang nháy. Thấp quá → spinner chồng lên nhau.
  2. Lỗi throw ra như error (không phải thenable) thì cần Error Boundary. Lồng chúng: Error Boundary ngoài hoặc cạnh Suspense để lazy() import fail là lỗi, không phải fallback chạy mãi.
  3. Tương đương Vue: Vue <Suspense> vẫn experimental và cover async setup. Nuxt thường thay bằng useAsyncData + pending. Đừng coi hai API là 1:1.
const Chart = lazy(() => import('./Chart'))

function Dashboard() {
  return (
    <ErrorBoundary fallback={<Failed />}>
      <Suspense fallback={<Spinner />}>
        <Chart />
      </Suspense>
    </ErrorBoundary>
  )
}

Điều gì xảy ra: Paint đầu hiện Spinner trong lúc Chart load. Import fail thì Error Boundary hiện Failed thay vì treo spinner. Không có Suspense, lazy() throw lúc runtime.

Vue / NuxtReact / Next
Tách codedefineAsyncComponentlazy() + Suspense
DatauseAsyncData pendingRSC / use() + Suspense
LỗionErrorCapturedError Boundary (không phải Suspense)

Góc nhìn senior

  • Đặt Suspense chỗ fallback có nghĩa: widget, tab, chart dưới fold, không phải lúc nào cũng cả trang.
  • Dùng Suspense thay Query khi team cần retry/cache là sai primitive. Query vẫn suspend được nếu bạn opt in.
  • React 19 use(promise) là cách client component suspend trên data. RSC async Server Component suspend trên server, không cần use.
  • Suspense lồng nhau lộ content khi từng child resolve. Một fallback top-level thì chờ child chậm nhất.
  • Vue Suspense vẫn experimental: nói rõ vậy. Flag pending của Nuxt mới là đường production mình ship trên Vue.

Lỗi hay gặp

  • Triệu chứng: lazy() throw “crashed”. Nguyên nhân: không có Suspense phía trên. Cách xử lý: bọc bằng fallback.
  • Triệu chứng: import fail treo spinner. Nguyên nhân: không có Error Boundary. Cách xử lý: lồng Error Boundary ngoài Suspense.
  • Triệu chứng: cả trang nháy vì một widget nhỏ. Nguyên nhân: Suspense quá cao. Cách xử lý: hạ boundary xuống.
  • Triệu chứng: coi Vue <Suspense> cùng API. Nguyên nhân: bản Vue experimental/async setup. Cách xử lý: nêu rõ khác biệt.

Người phỏng vấn hỏi gì

Widget lazy() không có boundary thì throw lúc runtime. Fetch data fail rồi trang treo spinner vì không có Error Boundary. Vue <Suspense> “cảm giác giống”. Suspense bắt gì, đặt ở đâu, và Vue có cùng nghĩa không?

Trả lời khi phỏng vấn

Suspense là boundary UI loading cho children đang suspend: lazy() và thenable data. Không phải error handler. Mình đặt chỗ fallback có nghĩa: cao quá thì cả trang nháy, thấp quá thì spinner chồng lên nhau. Error Boundary bắt lỗi; lồng ngoài Suspense để import fail là lỗi, không phải spinner chạy mãi. Vue Suspense vẫn experimental, cover async setup; pending useAsyncData của Nuxt mới là thứ mình ship. React 19 use(promise) là đường data phía client; RSC async component suspend trên server.

Nhớ nhanh

  • Suspense = UI loading cho children đang suspend. Không phải lỗi.
  • Đặt chỗ fallback có nghĩa. Lồng Error Boundary.
  • lazy() không Suspense thì throw. Import fail cần Error Boundary.
  • Vue <Suspense> experimental; đừng map 1:1.

Câu hỏi nối tiếp thường gặp

  • RSC khác client use(promise)? RSC suspend trên server. use suspend trong client component dưới Suspense.
  • Query + Suspense? Opt-in. Retry/cache vẫn nằm trong Query; Suspense chỉ vẽ lúc chờ.
  • Quá nhiều spinner? Một fallback parent, hoặc skeleton khớp layout.
  • Tương đương Nuxt? useAsyncData pending + <ClientOnly>, không lấy Vue Suspense làm mặc định.

Tóm tắt: Error Boundary bắt lỗi render / lifecycle / constructor ở descendant. Chúng không bắt event handler, code async, hay render của chính boundary. Trong React 19 chúng vẫn là class (componentDidCatch).

Vì sao quan trọng: Tooltip throw làm white screen cả product vì boundary duy nhất nằm ở App. Họ hỏi sao cái này lại là class, có bắt lỗi fetch không, và reset thế nào sau khi user sửa state.

  1. Cách hoạt động: Class implement getDerivedStateFromError và/hoặc componentDidCatch. Descendant throw lúc render thì React hiện UI this.state.hasError và báo lỗi. Production: react-error-boundary kèm Sentry.
  2. Reset: đổi key trên boundary, hoặc gọi resetErrorBoundary() sau khi user thử lại. Đặt boundary mức widget để phần còn lại của trang vẫn sống.
  3. Tương đương Vue: onErrorCaptured ở parent. Vue làm được trong function component. Core React thì không: tách một class ra.
class WidgetBoundary extends Component<{ children: React.ReactNode }, { err: Error | null }> {
  state = { err: null as Error | null }
  static getDerivedStateFromError(err: Error) {
    return { err } // render fallback
  }
  componentDidCatch(err: Error, info: React.ErrorInfo) {
    report(err, info) // Sentry: không bắt onClick hay fetch
  }
  render() {
    return this.state.err ? <Failed onRetry={() => this.setState({ err: null })} /> : this.props.children
  }
}

Điều gì xảy ra: Throw trong render của child thì hiện Failed. Throw trong onClick hoặc fetch().then thì không bị bắt: bạn vẫn cần try/catch ở đó. Retry xóa err để child mount lại.

Throw từError Boundary bắt?
Render / constructor của childCó
useEffect setup (đường render)Không: không phải lỗi render
onClick / event handlerKhông: try/catch trong handler
async / fetch rejectKhông: xử lý trong Query / promise

Góc nhìn senior

  • Boundary mức route khi cả trang chết; mức widget để phần còn lại vẫn sống. Một boundary App thì white screen cả product.
  • Error Boundary + Suspense: lazy() import fail phải thành lỗi, không được treo. Xem 20.2.3.
  • SSR: một số lỗi recover trên client; một số phải fail request. Đừng mặc định class bắt được fetch phía server.
  • Chiến lược reset: key={user.id} remount wizard sau crash. Ghi rõ điều đó. resetErrorBoundary của library cũng cùng ý.
  • Chaos-test: throw trong render của widget, xác nhận trang xung quanh vẫn sống. Throw trong onClick, xác nhận không bị bắt.

Lỗi hay gặp

  • Triệu chứng: một tooltip white screen tất cả. Nguyên nhân: chỉ có boundary App. Cách xử lý: boundary mức widget.
  • Triệu chứng: “sao không bắt fetch của mình?” Nguyên nhân: async nằm ngoài render. Cách xử lý: Query isError, hoặc try/catch.
  • Triệu chứng: retry không ăn. Nguyên nhân: render sau vẫn throw y như cũ. Cách xử lý: đổi data/key đã gây ra lỗi.
  • Triệu chứng: function component “ErrorBoundary”. Nguyên nhân: Hook không implement được componentDidCatch. Cách xử lý: class wrapper.

Người phỏng vấn hỏi gì

Tooltip throw làm white screen cả product vì boundary duy nhất nằm ở App. Họ hỏi sao cái này lại là class, có bắt lỗi fetch không, và reset thế nào sau khi user sửa state. Nó bắt gì, và không bao giờ bắt gì?

Trả lời khi phỏng vấn

Error Boundary bắt lỗi render, lifecycle, và constructor ở descendant. Không bắt onClick, async, hay render của chính nó. Core React 19 vẫn cần class cho componentDidCatch, nên mình dùng react-error-boundary và giữ phần còn lại là function. Mình đặt boundary mức widget để tooltip không white screen cả product. Reset là đổi key hoặc resetErrorBoundary sau khi user thử lại. onErrorCaptured của Vue sống được trong function component; React thì không. Lỗi fetch thuộc Query isError, không phải class này.

Nhớ nhanh

  • Bắt lỗi render của descendant. Không event, không async, không chính nó.
  • React 19: vẫn là class. Tách nó ra; phần còn lại để function.
  • Mức widget để trang vẫn sống. Chỉ App thì white screen cả product.
  • Reset qua key hoặc resetErrorBoundary. Vue: onErrorCaptured.

Câu hỏi nối tiếp thường gặp

  • Có bắt lỗi useEffect không? Không. Effect không nằm trên đường render. Guard ngay trong effect.
  • SSR? Lỗi render phía server thì fail request, hoặc recover trên client. Đừng mặc định class bắt được fetch phía server.
  • Library khác class tự viết? react-error-boundary cho reset + fallback. Mình vẫn thuộc hai method của class.
  • Quiz throw trong event handler? Không bắt. Đó chính là quiz.

Tóm tắt: Custom Hook là logic có state tái sử dụng, gọi Hook khác (useState, useEffect, Query). use* mà không gọi Hook là nói dối: linter tin cái tên. Composable Vue cùng bản năng extract, nhưng luật gọi tự do hơn.

Vì sao quan trọng: Có người prefix formatter thuần useFormatDate để trông “giống composable”, consumer gọi useUsers sau if (ready), và Hook fetch bằng useEffect trong khi app đã có Query.

  1. Cách hoạt động: Extract cùng useDebouncedSearch(query) ở cả hai stack: giá trị đã debounce kèm lời gọi Query/useAsyncData. Trong React hàm phải được gọi không điều kiện ở đầu component hoặc Hook.
  2. Nếu không gọi Hook, bỏ prefix use (formatDate). Nếu fetch, hãy gọi Query, đừng tự viết lại useEffect.
  3. Tương đương Vue: Composable gọi trong if / vòng lặp tự do hơn (vẫn là smell). Rules of Hooks của React là then chốt vì list slot trên fiber. Cùng kiểu extract, chỗ gọi chặt hơn.
// Cùng kiểu extract như composable Vue; Rules of Hooks vẫn áp dụng
export function useDebouncedSearch(query: string) {
  const [q, setQ] = useState(query)
  useEffect(() => {
    const t = setTimeout(() => setQ(query), 200)
    return () => clearTimeout(t)
  }, [query])
  return useQuery({ queryKey: ['search', q], queryFn: () => search(q) })
}

// Sai tên: không có Hook bên trong
export function formatDate(d: Date) {
  return d.toISOString().slice(0, 10)
}

Điều gì xảy ra: useDebouncedSearch luôn gọi useState, useEffect, và useQuery đúng thứ tự đó. Consumer phải gọi ở đầu, không được gọi sau if (ready). formatDate là helper; prefix use sẽ làm linter bỏ qua chỗ gọi trái luật.

Composable VueCustom Hook React
ExtractLogic có stateLogic có state
Luật gọiTự do hơn (if là smell)Không điều kiện; thứ tự là địa chỉ
TênuseX là quy ướcuseX là then chốt
Fetch bên tronguseAsyncDataQuery, không useEffect trần

Góc nhìn senior

  • Cùng bản năng extract như Vue. Khác ở chỗ gọi: React lưu state Hook theo thứ tự trên fiber.
  • Helper tên useX mà không gọi Hook còn tệ hơn if (x) useState(): chỗ gọi bỏ qua luật vì cái tên đã nói dối.
  • Đừng giấu fetch useEffect trong Hook khi Query đã có. Hook nên gọi Query.
  • Trả ra phần public nhỏ. Trả cả object Query thì ổn; trả năm field không liên quan là god Hook.
  • Test Hook bằng renderHook và Query client. Đừng unit-test như hàm thuần nếu chúng gọi Hook.

Lỗi hay gặp

  • Triệu chứng: lỗi Rules-of-Hooks sau một lần toggle. Nguyên nhân: useUsers nằm sau if (ready). Cách xử lý: luôn gọi; bật Query với enabled: ready.
  • Triệu chứng: useFormatDate gọi trong vòng lặp “chạy” đến khi ai đó thêm useState. Nguyên nhân: prefix use giả. Cách xử lý: đổi tên.
  • Triệu chứng: fetch hai lần ở mọi màn. Nguyên nhân: Hook bọc useEffect(fetch). Cách xử lý: Query trong Hook.
  • Triệu chứng: god Hook trả 20 field. Nguyên nhân: một composable cho cả trang. Cách xử lý: tách theo concern.

Người phỏng vấn hỏi gì

Có người prefix formatter thuần useFormatDate để trông “giống composable”, consumer gọi useUsers sau if (ready), và Hook fetch bằng useEffect trong khi app đã có Query. Custom Hook khác helper ở chỗ nào, và Rules of Hooks còn áp không?

Trả lời khi phỏng vấn

Custom Hook là composable: logic có state, gọi Hook khác. Nếu không gọi Hook thì prefix use là sai: linter tin cái tên. Cùng bản năng extract như Vue, nhưng use* ở đây là then chốt vì state Hook là list trên fiber. Mình không gọi useUsers sau if (ready); mình truyền enabled cho Query. Mình không giấu fetch useEffect trong Hook khi Query đã có. useDebouncedSearch là ví dụ mình viết: debounce kèm Query, gọi không điều kiện.

Nhớ nhanh

  • Custom Hook = logic có state, gọi Hook. Luật vẫn áp.
  • use* không gọi Hook là nói dối. Đổi tên helper.
  • Composable Vue tự do hơn lúc gọi; React thì theo vị trí.
  • Fetch trong Hook nên gọi Query, không useEffect trần.

Câu hỏi nối tiếp thường gặp

  • Gọi Hook trong vòng lặp? Không bao giờ. Truyền array vào một Hook, hoặc map ra component.
  • enabled: ready khác if (ready) useQuery? Luôn gọi; enabled bỏ qua request.
  • Share với Vue? Cùng hình hàm, khác luật gọi. Đừng copy-paste vào file React rồi giữ if.
  • Test? renderHook kèm Query client. Không phải useDebouncedSearch() trong unit test Node.

Tóm tắt: children là slot mặc định. Slot named và scoped thành header prop, compound component (Select.Item), hoặc hàm renderItem. Ưu tiên composition hơn mười hai boolean prop. Đừng cloneElement để giả scoped slot.

Vì sao quan trọng: <Button> có showIcon, showSpinner, isFullWidth, isDanger, asLink. Người chuyên Vue hỏi slot chạy thế nào, và một list inject props bằng cloneElement thì gãy trên Fragment.

  1. Cách hoạt động: Nếu parent đã biết cấu trúc, nên truyền JSX (children hoặc header={<…>}). Compound component chia state ngầm qua Context. renderItem={(row) => …} tương đương scoped slot.
  2. cloneElement để inject props gãy với Fragment, text node, và child đã memo. Ưu tiên props tường minh hoặc một render function.
  3. Tương đương Vue: Slot mặc định → children. Slot named → một prop (header) hoặc <Card.Header>. Scoped slot → renderItem. Vue viết tiện hơn; React tường minh hơn.
// Composition thay vì mớ boolean
function Card({ children, header }: { children: React.ReactNode; header?: React.ReactNode }) {
  return (
    <section>
      {header}
      <div>{children}</div>
    </section>
  )
}

// Tương đương scoped slot
function List({ items, renderItem }: { items: Item[]; renderItem: (item: Item) => React.ReactNode }) {
  return <ul>{items.map((item) => <li key={item.id}>{renderItem(item)}</li>)}</ul>
}

Điều gì xảy ra: Parent truyền header bằng JSX, nên Card không phình thêm flag showHeaderIcon. renderItem nhận row: scoped slot mà không cần cloneElement. Fragment trong children chạy bình thường.

VueReact
Slot mặc địnhchildren
Slot namedProp (header) / compound Card.Header
Scoped slotrenderItem / render prop
v-bind trên slotĐừng cloneElement: truyền props xuống

Góc nhìn senior

  • Nếu parent đã biết cấu trúc thì nên truyền JSX. Mười hai boolean nghĩa là component đang là file cấu hình, không phải UI primitive.
  • Compound component (Select.Item) là Context kèm quy ước đặt tên. Ghi rõ parent bắt buộc.
  • Pattern asChild / Slot (Radix) merge props vào element của user. Đó mới là cloneElement có kỷ luật.
  • children như hàm ({({ open }) => …}) là render prop. Một cái thì ổn; ba cái thì rối.
  • Mớ boolean là cách Button trở nên không test được. Tách Button, LinkButton, IconButton.

Lỗi hay gặp

  • Triệu chứng: 12 boolean trên Button. Nguyên nhân: tránh dùng slot. Cách xử lý: truyền children / tách variant.
  • Triệu chứng: cloneElement gãy với <>. Nguyên nhân: inject props vào child không biết trước. Cách xử lý: renderItem hoặc props tường minh.
  • Triệu chứng: compound Select.Item dùng ngoài Select. Nguyên nhân: thiếu Context của parent. Cách xử lý: throw lỗi rõ.
  • Triệu chứng: div bọc phá flex layout. Nguyên nhân: ép một node DOM quanh children. Cách xử lý: Fragment hoặc asChild.

Nhà tuyển dụng hỏi gì

<Button> có showIcon, showSpinner, isFullWidth, isDanger, asLink. Người chuyên Vue hỏi slot chạy thế nào, rồi một list inject props bằng cloneElement và gãy với Fragment. Map slot mặc định / named / scoped ra sao, và 12 boolean prop sai chỗ nào?

Trả lời khi phỏng vấn

children là slot mặc định. Slot named và scoped thành prop header, compound component, hoặc hàm renderItem. Mình ưu tiên composition hơn mười hai boolean: parent đã biết cấu trúc thì nên truyền JSX. cloneElement để inject props gãy với Fragment và child đã memo; mình truyền render function. Vue viết slot tiện hơn; React tường minh hơn. Mớ boolean là cách Button trở thành file cấu hình.

Nhớ nhanh

  • children là slot mặc định. Ưu tiên composition hơn mớ boolean.
  • Slot named → prop / compound. Scoped slot → renderItem.
  • Đừng cloneElement để giả scoped slot.
  • Slot của Vue tiện hơn; React tường minh hơn.

Câu hỏi nối tiếp thường gặp

  • asChild / Radix Slot? Merge có kỷ luật vào element của user. Vẫn không phải lý do để có 12 boolean.
  • Compound khác prop header? Compound khi vài chỗ named dùng chung state. Một chỗ thì làm prop được.
  • Vì sao Fragment làm cloneElement gãy? Không có một element để clone. Truyền một hàm.
  • Render prop khác children như hàm? Cùng ý. Đặt tên renderItem để contract hiện rõ.

Tóm tắt: Zustand là mặc định hình Pinia cho client UI. Redux Toolkit khi team cần quy ước hoặc đã dùng RTK Query. Jotai khi bài toán là đồ thị atomic. Không cái nào giữ list server: chỗ đó là Query hoặc RSC.

Vì sao quan trọng: “Redux hay Zustand?” xuất hiện trong vòng React, rồi đồng đội nhét list user vào slice “vì team dùng Redux”. Giữ câu trả lời ngắn: phần sâu nằm ở tài liệu state-management của React. Mặc định cho senior Vue là gì, và thứ gì không bao giờ được vào store?

  1. Cách hoạt động: Zustand: create((set) => ({ count, inc })) kèm một selector. RTK: configureStore + các slice + RTK Query tùy chọn. Jotai: atom kèm useAtom ở leaf. Chọn theo hình team, không theo Twitter.
  2. Tương đương Vue: Zustand ≈ Pinia (nhiều store nhỏ, compose lúc gọi). RTK ≈ Vuex kèm quy ước. Jotai ≈ nhiều ref nhỏ. Query ≈ useAsyncData. Đừng nhét row Product vào bất kỳ cái nào.
// Zustand: client UI hình Pinia
export const useCart = create<{ ids: string[]; add: (id: string) => void }>((set) => ({
  ids: [],
  add: (id) => set((s) => ({ ids: [...s.ids, id] })),
}))

// Jotai: một atom, subscribe ở leaf
export const idsAtom = atom<string[]>([])

// Đừng: list user trong Redux slice. Đó là Query / RSC.

Điều gì xảy ra: ids của cart nằm trong Zustand (state client kiểu session). Product detail lấy từ Query. Jotai cũng là ids đó nhưng dạng atom, nếu cây là đồ thị atomic. Slice users sẽ stale ngay lần admin sửa một row.

VueTương đương mặc định React
PiniaZustand
Vuex + quy ướcRedux Toolkit (+ RTK Query)
Nhiều ref nhỏAtom Jotai
useAsyncData / QueryTanStack Query / RSC, không phải store

Góc nhìn senior

  • Giữ câu trả lời ngắn và chỉ sang tài liệu state-management của React cho hydration và invalidation.
  • RTK khi org đã có, hoặc bạn cần toolkit (devtools, RTK Query, immutable update). App mới hình Vue: Zustand.
  • Jotai khi derived atom là mô hình. Đừng chọn để trông hiện đại trên admin CRUD.
  • Sau POST, invalidate Query. Đừng dual-write rồi coi store là sự thật. Cùng luật với Pinia.
  • Context là DI, không phải store thứ tư. Xem 20.2.1.

Lỗi hay gặp

  • Triệu chứng: list admin stale. Nguyên nhân: user nằm trong slice Redux/Zustand. Cách xử lý: Query / RSC có key.
  • Triệu chứng: “team dùng Redux” như kiến trúc. Nguyên nhân: không có tiêu chí. Cách xử lý: mặc định Zustand; RTK nếu đã có quy ước.
  • Triệu chứng: Context + Zustand + Redux. Nguyên nhân: không có owner. Cách xử lý: một store client, Query cho server, Context cho DI.
  • Triệu chứng: atom Jotai clone cây REST. Nguyên nhân: atom dùng như cache. Cách xử lý: Query.

Nhà tuyển dụng hỏi gì

“Redux hay Zustand?” xuất hiện trong vòng React, rồi đồng đội nhét list user vào slice “vì team dùng Redux”. Giữ câu trả lời ngắn: phần sâu nằm ở tài liệu state-management của React. Mặc định cho senior Vue là gì, và thứ gì không bao giờ được vào store?

Trả lời khi phỏng vấn

Cho client UI mình mặc định Zustand: nó hình Pinia. RTK khi team cần quy ước hoặc đã dùng RTK Query; Jotai khi bài toán là đồ thị atomic. Không cái nào giữ list user: chỗ đó là Query hoặc RSC. Sau POST mình invalidate cache; mình không dual-write rồi coi store là sự thật. Context là DI, không phải store thứ tư. Phần sâu về hydration và invalidation nằm ở tài liệu state-management của React. Trên sản phẩm admin, id cart để Zustand; row để Query.

Nhớ nhanh

  • Zustand là mặc định hình Pinia cho client UI.
  • RTK cho quy ước / RTK Query. Jotai cho đồ thị atomic.
  • Không library nào ở đây là chỗ để giữ list server.
  • Phần sâu: tài liệu state-management của React. Cùng cách tách như Pinia vs Query.

Câu hỏi nối tiếp thường gặp

  • RTK Query khác TanStack Query? Cùng việc. Chọn cái team đã có. Đừng chạy cả hai.
  • Vì sao không dùng Context làm store? Broadcast re-render. Xem 20.2.1.
  • Hydration Zustand? Một store mỗi request trên SSR, như Pinia. Chi tiết ở tài liệu state-management của React.
  • Khi nào Redux vẫn là câu trả lời? Codebase RTK sẵn có, hoặc cần một event log thống nhất.

Tóm tắt: Kiến trúc trước: state ở đâu, data server, bundle. Rồi mới tới Profiler. Folklore useMemo để cuối. Vue thường cảm giác đã tối ưu vì update fine-grained; senior React phải làm có chủ đích mới được cùng UX.

Vì sao quan trọng: “Làm trang này nhanh hơn thế nào?” Junior rải useMemo, bỏ Profiler, ship bảng 5k row, rồi fetch waterfall ở parent. Vue thì cảm giác “đã tối ưu sẵn”. Bạn đo gì trước, và lever nào là kiến trúc, lever nào là folklore?

  1. Cách hoạt động: Đo bằng React Profiler và một Performance trace (INP, long task). Nêu rõ bottleneck: network, JS, render, hoặc layout.
  2. Locality của state: input ở leaf không được re-render dashboard. List server để trong Query/RSC, không phải store. Key trên list. Virtualize 5k row.
  3. Bundle: tách theo route, lazy chart. Transition cho list không gấp. Compiler nếu org đã bật. useMemo chỉ khi Profiler đã chứng minh.
  4. Tương đương Vue: Đồ thị của Vue đã skip việc không dùng. React bắt bạn đặt state, key, và split bằng tay, hoặc bật Compiler.
// Profiler trước. Rồi locality. Rồi memo.
function Page() {
  const { data } = useQuery({ queryKey: ['rows'], queryFn: fetchRows })
  return (
    <>
      <SearchBox /> {/* state riêng: không được rerender table */}
      <VirtualTable rows={data ?? []} />
    </>
  )
}

Điều gì xảy ra: State search ở lại SearchBox, nên gõ không dựng lại 5k row. Row đến từ Query, không phải Zustand. Table thì virtualize. useMemo không có trong file cho đến khi Profiler nói mapper của row đang nóng.

LeverKhi nào là bugKhi nào là folklore
Locality của stateInput ở leaf rerender cả trangKhông bao giờ folklore
Query / RSCStore giữ list từ serverFetch trong useEffect “cho dễ kiểm soát”
VirtualizeHàng nghìn row DOMVirtualize 20 row
useMemo / memoProfiler hiện calculate / child đang nóngRải theo policy

Góc nhìn senior

  • Mình không bắt đầu bằng useMemo. Mình Profiler xem ai render và vì sao. Production: INP / long tasks, không phải đoán mò.
  • RSC và Compiler tồn tại vì re-render mặc định đắt. Có trong stack thì dùng; đừng cargo-cult trên Vite SPA.
  • Waterfall là chuyện kiến trúc: fetch độc lập thì gọi cùng lúc. Memo không sửa được chuỗi network.
  • Transition (startTransition) hạ ưu tiên list nặng để lúc gõ vẫn khẩn. Xem 20.2.10.
  • Bundle đứng cuối trong các lever kiến trúc, nhưng đứng đầu khi “team mình ship chart 400 kB trên route landing”.

Lỗi hay gặp

  • Triệu chứng: “thêm useMemo khắp nơi, vẫn chậm”. Nguyên nhân: chưa từng profile. Cách xử lý: Profiler, rồi một lever.
  • Triệu chứng: gõ làm dashboard giật. Nguyên nhân: state search lift quá cao. Cách xử lý: locality, không phải memo.
  • Triệu chứng: 5k <tr> trong DOM. Nguyên nhân: không windowing. Cách xử lý: virtualize.
  • Triệu chứng: waterfall ở parent. Nguyên nhân: child chờ JSON của parent. Cách xử lý: fetch song song / RSC.

Người phỏng vấn hỏi gì

“Làm sao cho trang này nhanh hơn?” Junior rải useMemo, bỏ Profiler, ship bảng 5k row, rồi fetch waterfall ở parent. Vue thì cảm giác “đã tối ưu sẵn”. Bạn đo gì trước, và lever nào mới là kiến trúc, lever nào chỉ là folklore?

Trả lời khi phỏng vấn

Mình không bắt đầu bằng useMemo. Vue thường cảm giác đã tối ưu vì update fine-grained; senior React có được cùng UX nhờ chỗ để state, Profiler, và tính năng framework: RSC, Compiler. Mình Profiler xem ai đã render. Input ở leaf không được rerender dashboard. List từ server để trong Query hoặc RSC. Hàng nghìn row thì mình virtualize. useMemo để cuối, khi Profiler hiện việc đang nóng. Waterfall là chuyện kiến trúc. Transition giữ gõ khẩn trong lúc list nặng bắt kịp.

Nhớ nhanh

  • Kiến trúc trước: locality của state, data server, bundle, rồi mới tới Profiler.
  • Folklore useMemo để cuối. Đo INP / long tasks, đừng đoán mò.
  • Vue mặc định skip việc không dùng. React bắt bạn tự đặt nó.
  • Hàng nghìn row thì virtualize. Đừng memo bảng 20 row chỉ vì policy.

Câu hỏi nối tiếp thường gặp

  • INP là gì? Interaction to Next Paint: thời gian đến khi UI trông như đã phản hồi. Field metric, không phải số chỉ đo trong lab.
  • Compiler thay memo? Org đã bật thì đo sau khi xóa memo viết tay. Xem 20.1.8.
  • Khi nào bundle là lever đầu? Route landing có chart 400 kB. Tách nó ra.
  • Transition khác debounce? Transition giữ input khẩn. Debounce bỏ việc. Hai việc khác nhau. Xem 20.2.10.

Tóm tắt: React SPA không có Edge middleware. RequireAuth kèm <Outlet /> là cổng UX sau khi JS load. Loader của React Router gần beforeEnter hơn, và vẫn chạy sau request. Next middleware.ts chạy trên Edge trước HTML: cookie, không phải RBAC.

Vì sao quan trọng: Người chuyên Vue/Nuxt lập tức lôi beforeEach / middleware/ ra và viết <Navigate to="/login" /> ngay lúc user là null. Trang login nháy, /login nằm cùng matcher “có cookie” với /app, và họ gọi đó là security.

  1. Cách hoạt động: SPA: bọc route cần bảo vệ trong layout đọc session rồi <Navigate>. Nó chạy sau bundle. Loader/action có thể redirect sớm hơn trong lifecycle SPA, vẫn trên client trừ khi bạn SSR.
  2. Next middleware.ts: có cookie, geo, A/B, redirect: trước HTML. Authorization nằm ở API / RSC / server. Matcher /login với “có cookie” thì loop.
  3. Tương đương Vue: Vue Router beforeEach ≈ wrapper/loader SPA. Route middleware của Nuxt chạy được cả server và client. Server middleware Nuxt/Nitro ≈ Edge middleware của Next. Không cái nào là source of truth của authz.
function RequireAuth() {
  const { user, ready } = useSession()
  if (!ready) return <Splash /> // tránh nháy login
  if (!user) return <Navigate to="/login" replace />
  return <Outlet />
}

// Next middleware.ts: cổng cookie TRƯỚC html, không phải RBAC
// if (!req.cookies.has('sid') && !req.nextUrl.pathname.startsWith('/login')) redirect

Điều gì xảy ra: ready tránh nháy /login lúc session đang hydrate. User chưa auth thì replace sang /login. Cổng cookie của Next chạy trước HTML và phải loại /login, không thì loop. Không check nào thay được 401 của API.

Vue / NuxtReact SPANext.js
beforeEachWrapper layout + <Navigate>middleware.ts + matcher
beforeEnterRedirect loader của routeRedirect RSC / forbidden()
Server middleware NuxtKhông (không có Edge)Edge, trước HTML
Authz: API / NitroAuthz: APIAuthz: API / RSC

Góc nhìn senior

  • Guard phía client là UX. Bundle /admin giấu đi không phải security: JS vẫn tới, và API phải 401/403.
  • SPA + Vite là lựa chọn thẳng thắn cho dashboard auth (không SEO, API sẵn). Next middleware thêm cổng cookie trước paint: UX tốt hơn, vẫn không phải RBAC.
  • Nháy: chờ ready (hoặc loader) trước <Navigate>. User null lúc paint đầu chưa phải logged-out.
  • Matcher phải sạch: đừng nhét /login vào “phải có cookie”. Loop chính là câu quiz.
  • middleware/ Vue chạy server+client gần Next hơn wrapper React SPA.

Lỗi hay gặp

  • Triệu chứng: trang login nháy. Nguyên nhân: <Navigate> khi session còn null. Cách xử lý: cổng ready hoặc loader.
  • Triệu chứng: redirect loop trên /login. Nguyên nhân: matcher middleware gồm luôn login. Cách xử lý: loại nó ra.
  • Triệu chứng: “đã bảo vệ xong route”. Nguyên nhân: chỉ wrapper phía client. Cách xử lý: API/RSC vẫn phải authorize.
  • Triệu chứng: gọi Navigate SPA là “middleware”. Nguyên nhân: từ vựng Vue. Cách xử lý: gọi đúng là cổng UX.

Người phỏng vấn hỏi gì

Người chuyên Vue/Nuxt lập tức lôi beforeEach / middleware/ ra và viết <Navigate to="/login" /> ngay lúc user là null. Trang login nháy, /login nằm cùng matcher “có cookie” với /app, và họ gọi đó là security. Bảo vệ route trong React SPA thế nào, và authorization là gì?

Trả lời khi phỏng vấn

React SPA không có Edge middleware. RequireAuth kèm Outlet là cổng UX sau khi JS load; loader React Router gần beforeEnter hơn và vẫn chạy sau request. Next middleware.ts chạy trên Edge trước HTML: có cookie, không phải RBAC. Authorization là API hoặc RSC. Mình chờ ready trước Navigate để login không nháy. Mình loại /login khỏi matcher cookie để không loop. Vue beforeEach map sang wrapper; server middleware Nuxt map sang Next Edge. Không cái nào thay được 401.

Nhớ nhanh

  • React SPA không có Edge middleware; guard phía client là UX.
  • Authorization là API / RSC. Navigate không phải security.
  • Chờ session ready, không thì login nháy. Loại /login khỏi matcher cookie.
  • Vue beforeEach ≈ wrapper. Server middleware Nuxt ≈ Next Edge.

Câu hỏi nối tiếp thường gặp

  • Loader khác wrapper layout? Loader redirect trước khi component trang chạy. Vẫn là client trừ khi SSR.
  • Next forbidden() / unauthorized()? Helper RSC. Vẫn dựa session phía server, không phải matcher.
  • Giấu chunk admin? Code split là UX và bundle nhỏ hơn, không phải authz. API vẫn phải 403.
  • Nuxt ssr: false + middleware? Đó là SPA bọc thêm framework. Nói thẳng cổng chạy ở đâu.

Tóm tắt: Concurrent React ngắt được một render để update khẩn (lúc gõ) không bị cây nặng chặn. startTransition đánh dấu setState không khẩn. useDeferredValue giữ value khẩn gần nhất cho child đắt. Chúng hạ ưu tiên việc; không làm cây O(n²) rẻ.

Vì sao quan trọng: Gõ vào filter thì input bị trễ vì list 20k row re-render trên cùng một update khẩn. Có người bọc mọi setState trong startTransition, người khác dùng useDeferredValue như debounce, rồi hỏi cái này có thay memo không.

  1. Cách hoạt động: Update khẩn (input) render ngay. Update transition (list) có thể bị ngắt nếu update khẩn mới tới. Input vẫn mượt; list bắt kịp sau.
  2. startTransition(() => setFilter(q)) bọc setState nặng. useDeferredValue(q) truyền q deferred vào child đắt, còn input thì controlled bởi q khẩn.
  3. Đây không phải debounce (bạn không bỏ việc). Đây không phải memo (bạn vẫn render; chỉ schedule sau). Fiber mới cho phép render ngắt được. Xem 20.1.1.
  4. Tương đương Vue: Đồ thị fine-grained của Vue thường giữ input rẻ mà không cần API. React 19 cần bạn đánh dấu phần không khẩn. Không có startTransition 1:1 bên Vue.
function FilterList({ rows }: { rows: Row[] }) {
  const [q, setQ] = useState('')
  const deferred = useDeferredValue(q) // list trễ; input thì không
  return (
    <>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      <HeavyList rows={rows} filter={deferred} />
    </>
  )
}

Điều gì xảy ra: Mỗi lần gõ thì update q khẩn, nên caret không trễ. HeavyList nhận deferred, lúc đó có thể vẫn là chuỗi trước, khi React chưa xong lần render list vừa rồi. Nếu state nặng tách khỏi input, hãy bọc setFilter trong startTransition.

Công cụBạn muốn
startTransitionsetState này không khẩn cấp
useDeferredValueChild có thể chậm hơn value này
useMemo / memoBỏ việc khi input bằng nhau
DebounceBỏ việc trung gian
VirtualizeÍt node DOM hơn

Góc nhìn senior

  • Đánh dấu setState nặng, không phải mọi setState. Bọc state của chính input thì caret biến mất.
  • useDeferredValue là pattern search-list: input khẩn, filter deferred. startTransition là pattern “navigate / set tab / set filter state”.
  • Chúng không thay virtualize hay key. DOM 20k row vẫn là DOM 20k row.
  • isPending từ useTransition là cách hiện pending state trên list mà không chặn input.
  • Concurrent React 19 đang bật. Bạn vẫn chọn update nào không khẩn cấp. Vue thường không cần API này.

Lỗi hay gặp

  • Triệu chứng: input vẫn lag. Nguyên nhân: startTransition bọc setState của input. Cách xử lý: bọc state của list, hoặc useDeferredValue trên child.
  • Triệu chứng: “giờ không cần virtualize”. Nguyên nhân: coi transition như vé miễn phí cho 20k row. Cách xử lý: vẫn phải virtualize.
  • Triệu chứng: dùng useDeferredValue như debounce. Nguyên nhân: kỳ vọng các lần gõ bị bỏ. Cách xử lý: debounce network; defer render.
  • Triệu chứng: mọi setState nằm trong transition. Nguyên nhân: policy. Cách xử lý: chỉ cái đắt.

Người phỏng vấn hỏi gì

Gõ vào filter thì input lag vì list 20k row re-render trên cùng một update khẩn. Có người bọc mọi setState trong startTransition, người khác dùng useDeferredValue như debounce, rồi hỏi cái này có thay memo không. Concurrent rendering là gì, và API nào hạ ưu tiên list?

Trả lời khi phỏng vấn

Concurrent React ngắt được một render, nên gõ không bị cây nặng chặn. Mình đánh dấu setState nặng bằng startTransition, hoặc truyền value deferred vào child đắt để input vẫn tức thì. Đó không phải debounce: mình không bỏ việc. Cũng không phải memo: mình vẫn render, muộn hơn. Fiber là thứ cho phép tạm dừng. Mình vẫn virtualize 20k row. Vue thường giữ input rẻ mà không cần API này vì update fine-grained. Mình không bao giờ bọc setState của chính input trong transition.

Nhớ nhanh

  • Concurrent React ngắt được render để update khẩn không bị chặn.
  • startTransition / useDeferredValue hạ ưu tiên việc. Chúng không phải debounce hay memo.
  • Search-list: input khẩn, filter deferred. Đừng bọc setState của input.
  • Vue hiếm khi cần API này. Vẫn phải virtualize 20k row.

Câu hỏi nối tiếp thường gặp

  • useTransition isPending? Hiện pending state trên list, không phải input bị chặn.
  • Cái này thay memo? Không. Memo skip khi input bằng nhau. Transition trì hoãn khi input khác.
  • Nhắc lại Fiber? Work unit liên kết; render có thể tạm dừng; commit vẫn sync. Xem 20.1.1.
  • Debounce fetch nữa? Có, nếu network nên bỏ các lần gõ. Defer render và debounce IO bổ sung nhau.

Tóm tắt: Bản map Vue → React cho phỏng vấn React 19. Đọc sau 20.1–20.2. Các hàng dễ trượt là ref ≠ useRef, watch ≠ useEffect, và Pinia ≠ REST cache.

Vì sao quan trọng: Người phỏng vấn bảo bạn diễn một phản xạ Vue trong một câu. Cheat sheet chính là câu đó theo từng hàng, rồi họ hỏi xoáy topic phía sau hàng.

  1. Cách hoạt động: Cột trái là từ Vue bạn đã biết. Trả lời bằng cột phải, rồi nêu bẫy của hàng đó (ví dụ useRef không phải ref()).
  2. Đừng thuộc lòng cả bảng. Chọn hàng họ hỏi rồi mở topic 20.1 / 20.2 khớp.
Vue 3React 19
SFC .vue.tsx component
Fine-grained reactivityRe-render + reconcile (+ Compiler bailout)
ref / reactiveuseState / useReducer (không phải useRef)
computedSuy ra lúc render / useMemo
watch / watchEffectuseEffect chỉ để sync bên ngoài
onMounteduseEffect(..., []) (Strict Mode remount)
provide / injectContext (broadcast re-render)
<Teleport>createPortal (event đi theo cây React)
ComposablesCustom Hooks (use*, Rules of Hooks)
v-modelControlled input; file uncontrolled; RHF khi scale
v-for + :key.map + key ổn định (Fragment keys)
PiniaZustand / RTK: không dùng cho list server
Router guard / Nuxt middlewareSPA wrapper/loader · Next Edge (không phải authz)

Góc nhìn senior

  • Ba hàng trượt phỏng vấn: ref() → useState, watch → không phải useEffect mặc định, Pinia → không phải Query.
  • React Compiler đổi hàng “bọc hết”. Đo trước khi đọc thuộc memo.
  • Context không phải provide. Event của portal không phải event DOM. Nói to hai câu đó.
  • Dùng sheet này để chọn topic, không phải để thay 20.1.
  • React 19: ref là prop; use(Context); concurrent đang bật. Các hàng vẫn đúng.

Lỗi hay gặp

  • Triệu chứng: UI đứng im sau useRef(0). Nguyên nhân: map ref() của Vue. Cách xử lý: useState. Xem 20.1.10.
  • Triệu chứng: fetch trong useEffect “vì onMounted”. Nguyên nhân: map lifecycle 1:1. Cách xử lý: Query / RSC. Xem 20.1.12.
  • Triệu chứng: list user trong Zustand. Nguyên nhân: map Pinia như REST cache. Cách xử lý: Query. Xem 20.2.7.
  • Triệu chứng: đọc thuộc cả 13 hàng. Nguyên nhân: coi sheet là câu trả lời. Cách xử lý: một hàng, rồi topic.

Người phỏng vấn hỏi gì

Họ chỉ một từ Vue (ref, watch, provide, Pinia) và muốn tương đương React 19 trong một câu, kèm cái bẫy. Cheat sheet là bản map đó; 20.1–20.2 là phần chứng minh.

Trả lời khi phỏng vấn

Mình coi đây là bản map, không phải kịch bản. Vue ref là useState, không phải useRef. watch không phải useEffect mặc định: effect sync thế giới bên ngoài. provide thì track; Context thì broadcast. Pinia là Zustand cho UI client, không phải REST cache. v-model là value và onChange. Mình chọn hàng họ hỏi rồi mở topic phía sau. React 19 không đổi các bẫy đó; nó thêm ref-as-a-prop, use(), và Compiler tùy chọn.

Nhớ nhanh

  • Vue ref() → useState. Vue template ref → useRef.
  • watch / onMounted không phải useEffect mặc định.
  • Context broadcast; Vue provide thì track. Pinia ≉ Query.
  • Một hàng, rồi topic 20.1 / 20.2 khớp. Đừng thuộc lòng cả bảng.

Câu hỏi nối tiếp thường gặp

  • Fiber / Compiler ở đâu? 20.1.1 và 20.1.8. Sheet chỉ nói “re-render + reconcile”.
  • Error Boundaries? Không có hàng, chúng vẫn là class. Xem 20.2.4.
  • Suspense vs Vue? Không 1:1. Xem 20.2.3.
  • Middleware Next vs Nuxt? Wrapper SPA khác Edge. Xem 20.2.9.