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ự.
- 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.
- 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). - commit phase áp dụng mutation DOM đồng bộ. Child re-render vì parent đã chạy, trừ khi nó bail out (
memo,useMemoidentity, hoặc Compiler). - Tương đương Vue: Vue 3 patch đồ thị reactivity, nên một
refkhô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 3 | React 19 | |
|---|---|---|
| Mô hình update | Patch đồ thị reactivity | Chạy lại hàm, rồi diff |
| Identity của list | :key | key trên element trong array |
| Bailout | Deps không dùng thì không chạy lại | memo / Compiler / cùng state ref |
| Fiber | Scheduler 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
memotrà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
memotay 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
memohoặc Compiler, đừng đoán mò.- Triệu chứng:
memokhông bao giờ skip. Nguyên nhân:style={{}}hoặconClickinline. 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ừ
FoosangBarmà 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.
keylà 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.
memocó 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
FoosangBarthì 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.
- 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/useEffectlên fiber. Thứ tự gọi phải giống nhau mọi render. Đó là lý do cóeslint-plugin-react-hooks. if (enabled) useEffect(...)nghĩa là sau khienabledđả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.- 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ặcreact-error-boundary) và giữ phần còn lại là function. - 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.
| Vue | React |
|---|---|
<script setup> | Function component + Hooks |
| Options API | Class component (legacy) |
Mixins / extends | Custom Hooks (compose) |
onErrorCaptured | Error Boundary class (componentDidCatch) |
| Thứ tự gọi tự do | Thứ 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ọiuseStatevẫ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 sauifthì 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ảieslint-disableexhaustive-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ànhuseOnlineStatusvà 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
useEffectEventhoặ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-hookslà tính đúng.use*đứng sauifthì 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ọiuseStatetệ hơnif (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
useEffectEventmớ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ảieslint-disabletrà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.
- 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 />}khicount === 0sẽ vẽ0. - Bạn trả
nullkhi 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. - 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. - 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-ifkhô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 / undefined | Không gì |
true | Không gì (boolean bị bỏ qua) |
0 / NaN / "" | Text node |
| Element / component | Câ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ảnullkhi chịu trách nhiệm API của component.v-showkhông có primitive: CSShiddenhoặc unmount. Đừng giả toggle display bằng&&.- Ưu tiên Fragment hơn
divbọ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.
undefinedkhi là prop nghĩa là “dùng mặc định”.undefinedkhi 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
0chỗ 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 trongmap. 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:
divbọ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ì
divbọ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. undefinedkhi 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.
- 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. setStatebail out nếuObject.isthấy cùng reference.user.name = 'x'; setUser(user)không làm gì. Copy:setUser({ ...user, name: 'x' })hoặc reducer / Immer.- 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.
- Tương đương Vue: Props Vue vẫn một chiều. Proxy
reactivethấyitems.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.
| Vue | React |
|---|---|
| Props một chiều | Props một chiều (cùng contract) |
| Proxy thấy mutation tại chỗ | Object.is cùng ref → bailout |
v-model / emit | value + onChange |
Pinia / ref là owner | useState / store / URL |
Góc nhìn senior
- Immutability giúp
memovàObject.isrẻ. 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ãymemochild “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
pushvào array prop; parent không bao giờ update. Nguyên nhân: mutate props. Cách xử lý:onChangevớ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ãymemochild? Object identity mới mỗi lần render, nênmemothấ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.
- 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ếuonChangethì field đứng im. Uncontrolled:defaultValuegán sẵn lên DOM; lúc submit bạn đọc qua ref hoặcFormData. - 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: đọce.target.files, đừng bao giờvalue={file}. - Tương đương Vue:
v-modellà đường tắt prop + emit. React không có đường tắt:valuekèmonChange, hoặc để DOM lo.defineModelvẫ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ách | Dùng khi | Chi phí |
|---|---|---|
| Controlled | Cần mọi lần gõ trong React | Re-render mỗi phím |
| Uncontrolled | Đọc lúc submit; widget native | Rẻ; khó validate live hơn |
| RHF / tương tự | Form trong app thật | Uncontrolled mặc định; Controller ở leaf |
| Input file | Luôn | Platform-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,
Controllerchỉ ở 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
valuecủa input thì cảm giác gãy (caret bị trễ). Debounce payload gửi server; giữ input chạy live. - shadcn/MUI
TextFieldphải đi quaControllervì chúng là controlled component.
Lỗi hay gặp
- Triệu chứng: field đứng im. Nguyên nhân:
valuemà không cóonChange. Cách xử lý: thêmonChange, hoặc chuyển sangdefaultValue.- 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ý: đọcFileList; reset bằnginputRef.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-modellà cặp này.
Câu hỏi nối tiếp thường gặp
- Gắn
TextFieldshadcn/MUI thế nào? Nó là controlled. Bọc trongControllercủ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
valuecả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()}.
- 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.
- Thiếu key → React fallback sang index bên trong rồi warn. Hai child trong
mapcần<Fragment key={id}>(<>ngắn không nhận key). Reorder với key index: input uncontrolled dính theo vị trí DOM. 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.- Tương đương Vue:
:keylà cùng contract identity. Vue cũng tái sử dụng nhầm instance component khi bạn gắn keyv-fortheo 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 key | Khi nào | Lỗi |
|---|---|---|
| Id nghiệp vụ | Mặc định | Hiếm (id trùng) |
| Index | Chỉ list tĩnh | Reorder → sai state local |
Math.random() | Không bao giờ | Remount mỗi lần render |
| Remount bằng key | Reset có chủ đích | Khô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ì
useEffectchạ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. idtừundefinedsang123trê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 trongmap. 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ũ.
:keycủ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
useEffectcleanup + 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
idtừundefinedsang123hủ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?
- 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). - 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
fetchRSC, xem 20.1.12. - Bạn có thể không cần effect:
useEffect(() => setX(transform(y)), [y])là derived state. Tínhtransform(y)lúc render. - Tương đương Vue:
watch/onMountedkhông phảiuseEffect.watchVue có thể flush trước paint; effect luôn chạy sau.watchEffecttự track; dep củauseEffectliệ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 ra | Render (const x = f(y)) |
| User click / submit | Event handler |
| Subscribe / widget DOM | useEffect + cleanup |
| Load document / list | RSC / 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+watchquá 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ặcAbortControllernế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 skipexhaustive-depstrà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 raf(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ý:
AbortControllerhoặ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/onMountedVue không phảiuseEffect.
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
idnên để ở đâu?queryKeycủa Query hoặcfetchRSC, không phảiuseEffect([id]). Xem 20.1.12. useLayoutEffectthay thế? Chỉ để đo hoặc cắt nháy trước paint. Xem 20.1.11.useEffectEventkháceslint-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ì?
- Cách hoạt động: Suy ra lúc render trước. Thêm
useMemocho (1) việc đã chứng minh là đắt hoặc (2) ổn định tham chiếu của object/array truyền vào childmemohoặc liệt kê trong dep effect. - Sai dep → memo stale. Dep là object mới mỗi lần → memo không bao giờ hit.
useMemolà cache, không bảo đảm việc bị skip khi concurrent render. - Tương đương Vue:
computedVue là lazy và tự track.useMemolà cache, dep bạn tự liệt kê. Đừng dịch mọicomputedthànhuseMemo.
// 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 computed | React useMemo | |
|---|---|---|
| Tracking | Tự động, lazy | Dep bạn liệt kê |
| Mặc định | Có, cho giá trị suy ra | Không, suy ra lúc render trước |
| Compiler | Reactivity đã track | React 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. useMemokhông làmmaprẻ 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
memovẫ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ê fieldopts, 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.
useMemokhông phảicomputed. - 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
useMemocó 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.
computedVue có setter? Vẫn là writable ref. Bên React thì làuseStatekè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í?
- Cách hoạt động:
useCallback(fn, deps)trả cùng function reference khidepsvẫn bằng nhau theoObject.is. Hàm mới mỗi lần render làmmemofail (prop đổi) và effect nào liệt kê callback sẽ chạy lại. - Dep không ổn định (
useCallback(() => do(x), [])vớixcũ) là ship stale closure. Liệt kêsetStatethì ổn (React bảo đảm identity của nó). Liệt kêstatekhi bạn muốn liệt kêsetStatethì callback bị tạo lại mỗi lần. - Tương đương Vue: Child Vue tự track dep của chúng, nên identity
onClickcủa parent hiếm khi quan trọng.memocủ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ọng | Bỏ useCallback |
|---|---|
Prop của child memo | <button> native, không memo |
Dep useEffect | Handler chỉ dùng trong JSX |
| Value của context | Compiler đã memo sẵn |
| Register bên third-party | Policy “bọc mọi handler” |
Góc nhìn senior
- React Compiler thường tự memo callback. Team đã bật Compiler: đừng rải
useCallbacktheo 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ỏuseCallbacktrừ khi thứ khác phụ thuộc identity. useEvent/useEffectEventlà 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
onSelectqua 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
memovẫ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:
filtercũ trongonSelect. Nguyên nhân:useCallback(..., []). Cách xử lý: liệt kêfilter, hoặc đọc từ ref.- Triệu chứng: Compiler +
useCallbackviế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.
memocủ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
useCallbackcó 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.
- Cách hoạt động:
useRef(init)trả{ current: init }. Gán.currentkhô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. - DOM ref:
ref={el}trên host node. React 19 thì function component nhậnrefnhư prop;forwardRefvẫn cần cho cây cũ và một số library.useImperativeHandleexpose một API instance nhỏ. - Tương đương Vue:
ref()của Vue là state reactive →useStatecủa React. Template ref Vue →useRefcủa React. Dịchref(0)thànhuseRef(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.
| Vue | React |
|---|---|
ref(0) (reactive) | useState(0) |
Template ref / useTemplateRef | useRef + ref={…} |
defineExpose | useImperativeHandle |
| Luôn là binding kiểu prop | React 19: ref là prop |
Góc nhìn senior
- Nếu tăng
.currentmà UI không paint, API đang chạy đúng. Nếu user phải thấy con số, bạn cầnuseState. - Với “latest props trong subscription ổn định”, ref kèm
useEffectEventhơn hẳn việc nhét callback vào list dep. useImperativeHandlelà 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ùnguseRefnhư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ậnrefnhư 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 = xkhông re-render. Đó là mục đích.- Đừng đọc/ghi ref trong thân render khi React concurrent.
- React 19:
reflà prop.forwardRefthườ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
useEffectEventtrong React 19. useImperativeHandlevs 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?
- 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. - Commit áp mutation DOM. Rồi
useLayoutEffectchạy trước khi browser paint: đo, hoặc diệt nháy. Rồi browser paint. RồiuseEffectchạy cho thế giới bên ngoài. - Unmount chạy cleanup cuối. Strict Mode lúc development remount một lần để chắc cleanup đúng.
- Tương đương Vue: Vue đặt tên theo hook (
onMounted,onUpdated,onBeforeUpdate). React đặt tên theo pha.onBeforeUpdategầnuseLayoutEffecthơn;onMountedgầnuseEffect, 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ép | Không được |
|---|---|---|
| Render | Đọc props/state; trả JSX | Network, setState, DOM, Date.now() |
| Commit | React ghi DOM | Code của bạn (hầu như) |
| Layout effects | Đo; sync DOM trước paint | Việc nặng (chặn INP) |
| Passive effects | Subscribe, fetch khi bắt buộc | Derived setState (vẽ một frame sai) |
Góc nhìn senior
- Mình không bắt đầu từ tên method class.
componentDidMountlà “sau paint, có thể hai lần trong Strict Mode”, không phải hook chạy một lần cho cả tab. useLayoutEffectchặn paint. Dùng nó đểsetStatetừgetBoundingClientRectdễ thrash, nhất là khiheightnằ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ý:useLayoutEffectkèm guard.- Triệu chứng: vòng layout vô hạn. Nguyên nhân:
setHeightluôn ghi,heighttrong deps. Cách xử lý: chỉ ghi khinext !== 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ỏ.
useLayoutEffectchặn paint. Mặc định dùnguseEffect.- 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
heighttrong 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?
- Cách hoạt động:
fetchcủ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. - Query là cache client (stale-while-revalidate, key, invalidation). Sau POST,
invalidateQueries. Race: ưu tiên cancel của Query, hoặcAbortControllernếu bạn chịu trách nhiệm effect đó. - Tương đương Vue:
useAsyncData/useFetchcủ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 | Ở đâu | Không |
|---|---|---|
| SEO / secret / document | Async Server Component | useEffect |
| Filter, polling, cache dùng chung | TanStack Query | List Redux / Zustand |
| Vite SPA nhỏ | Vẫn Query; effect là phương án cuối | God-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.alltrê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 +
AbortControllerchỉ 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
useEffecttrê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ý:
queryKeycủ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
AbortControllertrong 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
useEffectlà 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/useFetchtrong 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.
- Cách hoạt động:
createContext+Provider value={…}+use(Context)(React 19) hoặcuseContext. Identity củavalueđổi thì báo mọi consumer, kể cả những consumer chỉ đọctheme. - Tách context (
ThemeContextkhácSearchContext) để 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. - Tương đương Vue:
provide/injecttrackrefđược inject. Child không đọcsearchthì 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.
| Context | Zustand / Jotai | |
|---|---|---|
| Dùng cho | DI ít đổi | UI client hay đổi |
| Chi phí update | Mọi consumer | Subscriber theo selector |
memo giúp? | Không, nếu consumer use context | Có, nếu slice được chọn ổn định |
| Tương đương Vue | provide / 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
AppContextphì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ũnguseđượ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ọiuse(Context). Cách xử lý: đưausevà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.
memokhông skip consumer đãuse(Context).- Tách context hoặc dùng Zustand cho slice hay đổi.
providecủ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
memokhông giúp? Child vẫnusecontext, nên nó là consumer. Bọcusetrong 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ácuseContext? Cùng lần đọc.usecò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.
- 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-indextheo parent DOM;stopPropagationtrên DOM ancestor của trigger không thấy click từ portal. - 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-rootcó sẵn trong HTML. - 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ếuto.
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 to | Vào domNode |
| Event | Cây Vue | Cây React (không phải DOM) |
| Context | Vẫn parent Vue | Vẫn parent React |
| SSR | Target phải tồn tại | Giố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.stopPropagationtrên ancestor đó sẽ miss click. - Focus trap và
aria-modalvẫn quan trọng. Portal chỉ là layout, không phải a11y. - Next: để
#modal-roottrong HTML layout gốc. Đừngdocument.bodylú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.
disabledtrên Teleport Vue không có one-liner; skipcreatePortalcó đ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 rabody/#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
createPortalvẽ 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.
nullkhá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-indexwar? Theo parent DOM. Vì vậy#modal-rootnằm cuốibody.- Vue
disabledtrên Teleport? SkipcreatePortalcó đ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?
- 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. - 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. - Tương đương Vue: Vue
<Suspense>vẫn experimental và cover asyncsetup. Nuxt thường thay bằnguseAsyncData+ 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 / Nuxt | React / Next | |
|---|---|---|
| Tách code | defineAsyncComponent | lazy() + Suspense |
| Data | useAsyncData pending | RSC / use() + Suspense |
| Lỗi | onErrorCaptured | Error 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. RSCasyncServer Component suspend trên server, không cầnuse. - 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ằngfallback.- 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.usesuspend 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?
useAsyncDatapending +<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.
- Cách hoạt động: Class implement
getDerivedStateFromErrorvà/hoặccomponentDidCatch. Descendant throw lúc render thì React hiện UIthis.state.hasErrorvà báo lỗi. Production:react-error-boundarykèm Sentry. - Reset: đổi
keytrên boundary, hoặc gọiresetErrorBoundary()sau khi user thử lại. Đặt boundary mức widget để phần còn lại của trang vẫn sống. - 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 child | Có |
useEffect setup (đường render) | Không: không phải lỗi render |
onClick / event handler | Không: try/catch trong handler |
async / fetch reject | Khô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
Appthì 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
fetchphía server. - Chiến lược reset:
key={user.id}remount wizard sau crash. Ghi rõ điều đó.resetErrorBoundarycủ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ặctry/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ỉ
Appthì white screen cả product. - Reset qua
keyhoặcresetErrorBoundary. Vue:onErrorCaptured.
Câu hỏi nối tiếp thường gặp
- Có bắt lỗi
useEffectkhô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
fetchphía server. - Library khác class tự viết?
react-error-boundarycho 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.
- 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. - Nếu không gọi Hook, bỏ prefix
use(formatDate). Nếu fetch, hãy gọi Query, đừng tự viết lạiuseEffect. - 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 Vue | Custom Hook React | |
|---|---|---|
| Extract | Logic có state | Logic có state |
| Luật gọi | Tự do hơn (if là smell) | Không điều kiện; thứ tự là địa chỉ |
| Tên | useX là quy ước | useX là then chốt |
| Fetch bên trong | useAsyncData | Query, 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
useXmà không gọi Hook còn tệ hơnif (x) useState(): chỗ gọi bỏ qua luật vì cái tên đã nói dối. - Đừng giấu fetch
useEffecttrong 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
renderHookvà 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:
useUsersnằm sauif (ready). Cách xử lý: luôn gọi; bật Query vớienabled: ready.- Triệu chứng:
useFormatDategọi trong vòng lặp “chạy” đến khi ai đó thêmuseState. Nguyên nhân: prefixusegiả. 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
useEffecttrầ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: readykhácif (ready) useQuery? Luôn gọi;enabledbỏ 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?
renderHookkèm Query client. Không phảiuseDebouncedSearch()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.
- Cách hoạt động: Nếu parent đã biết cấu trúc, nên truyền JSX (
childrenhoặcheader={<…>}). Compound component chia state ngầm qua Context.renderItem={(row) => …}tương đương scoped slot. 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.- 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.
| Vue | React |
|---|---|
| Slot mặc định | children |
| Slot named | Prop (header) / compound Card.Header |
| Scoped slot | renderItem / 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àcloneElementcó kỷ luật. childrennhư 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ềnchildren/ tách variant.- Triệu chứng:
cloneElementgãy với<>. Nguyên nhân: inject props vào child không biết trước. Cách xử lý:renderItemhoặc props tường minh.- Triệu chứng: compound
Select.Itemdùng ngoàiSelect. Nguyên nhân: thiếu Context của parent. Cách xử lý: throw lỗi rõ.- Triệu chứng:
divbọc phá flex layout. Nguyên nhân: ép một node DOM quanhchildren. Cách xử lý:FragmenthoặcasChild.
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
childrenlà 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
cloneElementgãy? Không có một element để clone. Truyền một hàm. - Render prop khác
childrennhư hàm? Cùng ý. Đặt tênrenderItemđể 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?
- 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:atomkèmuseAtomở leaf. Chọn theo hình team, không theo Twitter. - 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.
| Vue | Tương đương mặc định React |
|---|---|
| Pinia | Zustand |
| Vuex + quy ước | Redux Toolkit (+ RTK Query) |
| Nhiều ref nhỏ | Atom Jotai |
useAsyncData / Query | TanStack 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?
- 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.
- 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.
- Bundle: tách theo route,
lazychart. Transition cho list không gấp. Compiler nếu org đã bật.useMemochỉ khi Profiler đã chứng minh. - 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.
| Lever | Khi nào là bug | Khi nào là folklore |
|---|---|---|
| Locality của state | Input ở leaf rerender cả trang | Không bao giờ folklore |
| Query / RSC | Store giữ list từ server | Fetch trong useEffect “cho dễ kiểm soát” |
| Virtualize | Hàng nghìn row DOM | Virtualize 20 row |
useMemo / memo | Profiler hiện calculate / child đang nóng | Rả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
useMemokhắ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.
- 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. - Next
middleware.ts: có cookie, geo, A/B, redirect: trước HTML. Authorization nằm ở API / RSC / server. Matcher/loginvới “có cookie” thì loop. - 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 / Nuxt | React SPA | Next.js |
|---|---|---|
beforeEach | Wrapper layout + <Navigate> | middleware.ts + matcher |
beforeEnter | Redirect loader của route | Redirect RSC / forbidden() |
| Server middleware Nuxt | Không (không có Edge) | Edge, trước HTML |
| Authz: API / Nitro | Authz: API | Authz: API / RSC |
Góc nhìn senior
- Guard phía client là UX. Bundle
/admingiấ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
/loginvà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ònnull. Cách xử lý: cổngreadyhoặ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
NavigateSPA 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.
Navigatekhông phải security. - Chờ session
ready, không thì login nháy. Loại/loginkhỏ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.
- 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.
startTransition(() => setFilter(q))bọc setState nặng.useDeferredValue(q)truyềnqdeferred vào child đắt, còn input thì controlled bởiqkhẩn.- Đâ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. - 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ó
startTransition1: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 |
|---|---|
startTransition | setState này không khẩn cấp |
useDeferredValue | Child có thể chậm hơn value này |
useMemo / memo | Bỏ việc khi input bằng nhau |
| Debounce | Bỏ việc trung gian |
| Virtualize | Ít node DOM hơn |
Góc nhìn senior
- Đánh dấu
setStatenặng, không phải mọisetState. Bọc state của chính input thì caret biến mất. useDeferredValuelà pattern search-list: input khẩn, filter deferred.startTransitionlà pattern “navigate / set tab / set filter state”.- Chúng không thay virtualize hay key. DOM 20k row vẫn là DOM 20k row.
isPendingtừuseTransitionlà 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:
startTransitionbọcsetStatecủa input. Cách xử lý: bọc state của list, hoặcuseDeferredValuetrê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
useDeferredValuenhư 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
setStatenằ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/useDeferredValuehạ ưu tiên việc. Chúng không phải debounce haymemo.- Search-list: input khẩn, filter deferred. Đừng bọc
setStatecủ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
useTransitionisPending? 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.
- 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ụ
useRefkhông phảiref()). - Đừ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 3 | React 19 |
|---|---|
SFC .vue | .tsx component |
| Fine-grained reactivity | Re-render + reconcile (+ Compiler bailout) |
ref / reactive | useState / useReducer (không phải useRef) |
computed | Suy ra lúc render / useMemo |
watch / watchEffect | useEffect chỉ để sync bên ngoài |
onMounted | useEffect(..., []) (Strict Mode remount) |
provide / inject | Context (broadcast re-render) |
<Teleport> | createPortal (event đi theo cây React) |
| Composables | Custom Hooks (use*, Rules of Hooks) |
v-model | Controlled input; file uncontrolled; RHF khi scale |
v-for + :key | .map + key ổn định (Fragment keys) |
| Pinia | Zustand / RTK: không dùng cho list server |
| Router guard / Nuxt middleware | SPA 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ảiuseEffectmặ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:
reflà 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: mapref()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/onMountedkhông phảiuseEffectmặc định.- Context broadcast; Vue
providethì 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.