Bài này nói về state React/Next mức senior cho phỏng vấn, map từ Vuex/Pinia. Nó đi qua tầng nào chịu trách nhiệm một fact (URL, cookie/RSC, Query, Context, Zustand), vì sao list server không thuộc Redux, luồng một chiều khác store mutable của Vue, và những bug production người phỏng vấn hay tái sử dụng. Dành cho engineer đã ship Pinia, giờ cần biết đặt state ở đâu, không phải tour thư viện.
Đọc 22.1 và 22.4 trước (các tầng, rồi luồng quyết định). Người phỏng vấn xoáy “Redux, Context, hay Query?” nhiều hơn cú pháp slice. Rồi 22.5 cho queryKey / invalidation, 22.6 cho App Router, 22.7 cho bản map Pinia, 22.8 cho snippet họ đưa. Nêu rõ owner của fact trong một câu trước khi nêu tên thư viện.
Tóm tắt: Đặt mỗi fact vào tầng hẹp nhất vẫn share đúng: theo nguồn sự thật và vòng đời, không phải “global khác local”. URL, cookie/RSC, Query, Context, và Zustand là các owner khác nhau. Một fact, một owner. Analog Vue: ref / route.query / useCookie / useAsyncData / Pinia.
Vì sao quan trọng: “Redux, Context, hay Query?” cho bảng product, giỏ khách, user hiện tại, và theme toggle là phần khởi động. Đổ hết Pinia vào Redux là miss của senior Vue. Nếu hai tầng cùng nhận một field, bạn đã có bug.
- Cách hoạt động: UI local (
useState) chết cùng component. State lifted sống ở parent gần nhất mà hai sibling share. Context là dependency injection ít đổi (theme, locale,QueryClient). Store client giữ một graph chẳng đáng nằm trên URL, cũng không phải data server. - URL (
searchParams) thắng filter, tab, pagination: share được và nút back. Cookie / session server thắng login. RSC + TanStack Query thắng list server. Thứ tự Next: URL và cookie thắng store client khi cần reload, share, và tin cậy. Xem 22.6. - Hầu hết màn hình là URL + Query + vài
useState. Store xuất hiện khi workflow phía client là một graph (giỏ khách, editor nhiều bước). Analog Vue: cũng đừng đổroute.queryvào Pinia.
// Bốn fact, bốn owner: nói “Redux” cho cả bốn là miss
export function Catalog({ searchParams }: { searchParams: { q?: string } }) {
const q = searchParams.q ?? '' // URL chịu trách nhiệm filter
const { data: items } = useQuery({ queryKey: ['items', q], queryFn: () => api.items(q) })
const [open, setOpen] = useState(false) // UI local: modal
const qty = useCart((s) => s.items.length) // graph client: giỏ khách
return (
<>
<p>Cart {qty}</p>
<button onClick={() => setOpen(true)}>Filters</button>
{open && <FilterSheet q={q} />}
<ul>{items?.map((it) => <li key={it.id}>{it.title}</li>)}</ul>
</>
)
}
// User hiện tại: cookie httpOnly + RSC. Theme: Context. Đừng gộp chúng ở đây.Điều gì xảy ra: Filter sống sau reload và share vì nó nằm trên URL. List là Query, key theo q. Modal là local. Giỏ là store client vì graph giỏ khách không phải list server. Login không nằm trong file này: nó là cookie.
| Tầng | Nguồn sự thật | Ví dụ |
|---|---|---|
| UI local | useState / useReducer | Modal mở, hover |
| UI lifted | Parent chung gần nhất | Filter + list sibling |
| Context | Giá trị Provider (ít đổi) | Theme, locale, QueryClient |
| Store client | Zustand / RTK / Jotai | Giỏ khách, editor nhiều bước |
| Server cache | RSC fetch + Query / SWR | List product, detail |
| URL / session | searchParams / cookie httpOnly | Filter, tab, ai đang login |
Góc nhìn senior
- Một fact, một owner. Field Zustand copy
searchParams.qlà nguồn sự thật thứ hai. - URL + cookie thắng store client khi cần reload, share, và tin cậy. Query/RSC chịu trách nhiệm list server.
- Hầu hết màn hình không cần store. Đừng bịa store cho bảng user.
- Analog Vue:
route.queryở lại router; Pinia không phải REST cache. - 22.6 là chỗ đặt riêng cho Next. Topic này là bản map tầng.
Lỗi hay gặp
- Triệu chứng: filter nằm cả Zustand lẫn URL. Nguyên nhân: hai owner. Cách xử lý: URL thắng; derive UI từ
searchParams.- Triệu chứng: list user trong slice Redux. Nguyên nhân: đổ nguyên Pinia. Cách xử lý: Query / RSC.
- Triệu chứng: user hiện tại trong Context, cập nhật mỗi lần gõ. Nguyên nhân: gộp draft. Cách xử lý: tách ra; session là cookie.
- Triệu chứng: theme trong store giỏ. Nguyên nhân: một túi global. Cách xử lý: Context cho theme.
Nhà tuyển dụng hay hỏi gì
Một bảng product, giỏ khách, user hiện tại, và theme toggle: Redux, Context, hay Query? Họ muốn mỗi fact được đặt theo nguồn sự thật và vòng đời, kể cả URL có phải store không, dưới 30 giây.
Trả lời khi phỏng vấn
Mình đặt state theo nguồn sự thật và vòng đời, không theo global khác local. Filter nằm trên URL, session nằm ở cookie httpOnly, list nằm ở Query hoặc RSC, còn Zustand chỉ giữ graph client chẳng đáng nằm trên URL, cũng không phải data server. Theme là Context. Modal là useState. Nếu hai tầng cùng nhận một field thì mình đã có bug. Đổ kiểu Pinia vào Redux là cách có cache thứ hai. Hầu hết màn hình là URL, Query, và vài useState. Mình sẽ đặt bốn fact đó vào bốn tầng, dưới 30 giây.
Nhớ nhanh
- Một fact, một owner. Tầng hẹp nhất vẫn share đúng.
- URL + cookie thắng store client khi cần reload, share, và tin cậy.
- Query / RSC chịu trách nhiệm list server. Đừng đổ hết vào Redux.
- Hầu hết màn hình là URL + Query + vài
useState.
Câu hỏi nối tiếp thường gặp
- Đặt filter, user hiện tại, giỏ khách, theme? URL, cookie+RSC, Zustand, Context.
- Zustand derive từ URL? Đọc thì được. Ghi lệch là nguồn sự thật thứ hai.
- RSC đã fetch list rồi Query fetch lại? Nêu rõ handoff: dehydrate, hoặc document RSC + Query cho tương tác live.
- Chỗ đặt riêng Next? 22.6.
Tóm tắt: Chọn store client theo team và hình dạng của graph, không bao giờ cho list server. RTK là lối đi sẵn. Zustand là DX kiểu Pinia nếu bạn bắt selector. Jotai là atom khi một store sẽ thành god object. Đừng chạy RTK Query và TanStack Query cùng lúc.
Vì sao quan trọng: “Redux đã chết?” và “store nào cho team 20 người?” muốn một lựa chọn theo quy mô team, không phải Twitter. Họ sẽ đánh rớt nếu list user nằm trong slice. Analog Vue: Vuex khác Pinia khác nhiều ref nhỏ.
- Cách hoạt động: RTK:
configureStore+ slice + convention + DevTools. Mặc định cho team 15–40 cần lối đi sẵn, middleware log sự kiện, hoặc RTK Query đã có sẵn. - Zustand:
create((set, get) => …)+ selector hẹp. Mặc định cho solo / product nhỏ nếu bạn viết convention selector. Không selector thì re-render loạn như Context. Jotai: nhiều atom, atom derived, cho editor và graph node. - Cùng một feature trong mỗi cái:
qtycủa giỏ. RTKuseSelector(s => s.cart.items.length). ZustanduseCart(s => s.items.length). JotaiuseAtomValue(qtyAtom). Không cái nào fetch catalog. Analog Vue: Vuex / Pinia / nhiều ref: fetch vẫn không nằm trong store.
// Cùng một fact, qty giỏ, ba store. Không cái nào chịu trách nhiệm catalog.
// RTK: const qty = useSelector((s: Root) => s.cart.items.length)
// Zustand
const useCart = create<Cart>((set) => ({
items: [] as { id: string }[],
add: (id: string) => set((s) => ({ items: [...s.items, { id }] })),
}))
const qty = useCart((s) => s.items.length) // selector là ràng buộc production
// Jotai
const itemsAtom = atom<{ id: string }[]>([])
const qtyAtom = atom((get) => get(itemsAtom).length)
// Đừng: users[] trong bất kỳ cái nào. Đó là Query.
// Đừng chạy RTK Query và TanStack Query trong cùng app.Điều gì xảy ra: Qty là lần đọc từ graph phía client. Selector Zustand subscribe items.length, không phải cả store. Jotai derive qty từ atom items. Catalog vắng mặt có chủ đích: đó là 22.5.
| Redux Toolkit | Zustand | Jotai | |
|---|---|---|---|
| Hình dạng | Một store, slice | Một hoặc vài store | Nhiều atom |
| Analog Vue | Vuex | Pinia | Nhiều ref nhỏ |
| Team 15–40 | Lối đi sẵn mặc định | Nếu bạn viết convention | Nếu domain là đồ thị |
| Async / list | RTK Query nếu đã RTK | TanStack Query | TanStack Query |
Góc nhìn senior
- Redux là tùy chọn, không phải đã chết. “Chết” là câu trả lời kiểu Twitter.
- Zustand không selector thì thrash y như Context quá nặng. Team 20 người thì viết convention.
- Sync offline / CRDT là lý do hợp lệ hiếm hoi để giữ list trong slice. Lúc đó bạn không còn là “the cache”.
- Đừng chạy RTK Query và TanStack Query cùng lúc. Chọn một cache HTTP.
- Vue: Vuex là lối đi sẵn, Pinia là DX, nhiều ref cho đồ thị. Fetch vẫn rời store.
Lỗi hay gặp
- Triệu chứng: user trong slice. Nguyên nhân: “đã có Redux”. Cách xử lý: Query. Xem 22.5.
- Triệu chứng:
useCart()cả store. Nguyên nhân: thói quen Pinia. Cách xử lý:(s) => s.items.length.- Triệu chứng: RTK Query kèm TanStack Query. Nguyên nhân: hai lối đi sẵn. Cách xử lý: chọn một.
- Triệu chứng: Jotai cho flag theme. Nguyên nhân: theo mốt. Cách xử lý: Context.
Nhà tuyển dụng hay hỏi gì
Redux chết chưa, và store nào cho team 20 người? Họ muốn bạn chọn theo quy mô team / hình dạng bài toán, và sẽ đánh rớt nếu list user nằm trong slice.
Trả lời khi phỏng vấn
Redux không chết; nó là tùy chọn. Mình chọn RTK khi org cần lối đi sẵn, Zustand khi muốn DX kiểu Pinia và sẽ bắt selector, Jotai khi domain là đồ thị và một store sẽ thành god object. Mình không nhét list server vào cái nào. Đó là Query. Zustand không selector thì thrash y như Context quá nặng. Mình không chạy RTK Query và TanStack Query cùng lúc. Analog Vue: Vuex khác Pinia khác nhiều ref. Bảng user không phải lý do mình mở topic này.
Nhớ nhanh
- RTK = lối đi sẵn. Zustand = DX Pinia + selector. Jotai = đồ thị / atom.
- Đừng để list server trong Redux / Zustand / Jotai theo mặc định.
- Đừng chạy RTK Query và TanStack Query cùng lúc.
- Kỷ luật selector là ràng buộc production của Zustand.
Câu hỏi nối tiếp thường gặp
- RTK Query khác TanStack Query? Một cache HTTP. RTK Query nếu đã dùng RTK; không thì TanStack.
- Zustand trên team 20? Viết convention selector và thư mục, hoặc chọn RTK.
- Khi nào slice được giữ list? Replication offline-first / CRDT. Lúc đó bạn không phải “the cache”.
- Context khác các store này? 22.4. Context là DI ít đổi.
Tóm tắt: RTK, Zustand, và Jotai là luồng UI client. List HTTP rời diagram này sang Query hoặc RSC. Bạn subscribe bằng selector hẹp. useCart() cả store là thrash hạng Context. Pinia của Vue thì mutate; các store này thay một slice (Immer hoặc object mới).
Vì sao quan trọng: “Vẽ luồng data Redux”, rồi “GET list nằm ở đâu, Zustand khác Pinia thế nào, và vì sao useCart() không selector làm cả cây thrash?” Trộn fetch trong set là tự viết lại thunk mà không có cache. Xem React 20.1.4 về bất biến khác mutation của Vue.
- Cách hoạt động: RTK: UI
dispatch→ reducer slice cập nhật bất biến (Immer) → thunk / listener / RTK Query cho async →useSelectorre-render. Zustand: UI gọiuseCart(s => s.add)(id)→setthay một slice → subscriber có slice đã chọn đổi theoObject.isthì re-render. - Jotai:
setAtom→ atom derive tính lại → chỉ component đã subscribe mới re-render. Action nên là cập nhật store thuần hoặc gọi mutation Query. Đừngfetchtrongset. - Analog Vue: Pinia mutate
cart.items.push. Zustand/RTK thay array. Cùng kết quả user thấy; chuyện identity thì khác. Server Component không dispatch vào Redux được. Trên server không có store phía client.
// Kỷ luật selector là ràng buộc production.
const qty = useCart((s) => s.items.length) // Object.is trên một số
const add = useCart((s) => s.add)
// Subscribe cả store = thrash (mỗi lần gõ nếu draft nằm ở đây)
// const cart = useCart()
add('sku-1')
// set Zustand phải thay: set({ items }) sau mutate tại chỗ thì identity array không đổi.
// const items = get().items; items.push(row); set({ items }) // ❌ cùng reference
// set((s) => ({ items: [...s.items, row] })) // ✅
// List HTTP GET không xuất hiện trong file này. Đó là Query.Điều gì xảy ra: qty chỉ re-render khi length đổi. add lấy action với reference ổn định. Mutate items tại chỗ rồi set({ items }) giữ cùng identity array, nên subscriber chọn items có thể miss lần cập nhật. List GET vắng mặt có chủ đích.
| Feature | RTK | Zustand | Jotai |
|---|---|---|---|
| Boilerplate | Trung bình | Thấp | Thấp–trung bình |
| Cấu trúc | Convention chặt | Bạn tự định nghĩa | Đồ thị atom |
| Cầu nối Vue | Vuex | Pinia | Nhiều ref |
| Side effect | thunk / RTK Query | Action mỏng + Query | Atom ghi mỏng + Query |
Góc nhìn senior
- List HTTP GET rời diagram này. Vẽ
fetchtrong reducer là miss. - Flux dùng khi bạn cần nhật ký action. Ghi kiểu Pinia thì ship nhanh hơn. Chọn theo lỗi bạn không chịu nổi.
- Server Component không dispatch được. Store sống trong Client island.
- Mutate tại chỗ rồi
set({ items })là thói quen Vue, fail thầm. - Xem 20.1.4 về bất biến props khác state. Topic này là đường subscribe của store.
Lỗi hay gặp
- Triệu chứng: cây thrash lúc thêm vào giỏ. Nguyên nhân:
useCart()cả store. Cách xử lý: selector.- Triệu chứng: UI cũ sau
items.push. Nguyên nhân: cùng identity array. Cách xử lý: thay array.- Triệu chứng:
fetchtrongsetcủa Zustand. Nguyên nhân: tự viết lại thunk. Cách xử lý: mutation Query.- Triệu chứng: dispatch từ Server Component. Nguyên nhân: không có client. Cách xử lý: Client island hoặc Action.
Nhà tuyển dụng hay hỏi gì
Vẽ luồng data Redux. Rồi: GET list nằm ở đâu, Zustand khác Pinia thế nào, và vì sao useCart() không selector làm cả cây thrash?
Trả lời khi phỏng vấn
Cả ba là luồng UI client. List GET rời diagram này sang Query hoặc RSC. Mình subscribe bằng selector hẹp. useCart cả store thì thrash y như Context. Action Zustand giữ mỏng và gọi Query cho HTTP; nhét fetch trong set là tự viết lại thunk mà không có cache. Pinia thì mutate; mình thay slice. Nếu team cần nhật ký action để ship, mình muốn RTK. Server Component không dispatch được. Store là Client island. Bất biến khác mutation Vue nằm ở 20.1.4.
Nhớ nhanh
- List HTTP GET rời diagram store sang Query / RSC.
- Selector hẹp. Subscribe cả store là thrash hạng Context.
- Pinia mutate; RTK/Zustand thay slice. Cùng UI, identity khác.
- Flux để audit; ghi kiểu Pinia nhanh hơn. Chọn theo lỗi bạn không chịu nổi.
Câu hỏi nối tiếp thường gặp
- Bất biến khác mutation Vue? 20.1.4. Ở đây: thay slice; đừng mutate rồi gán cùng reference.
- Mutate items tại chỗ rồi set({ items })? Subscriber chọn items có thể miss lần cập nhật.
- Vì sao Server Component không dispatch được? Không có store client trên server. Action hoặc Client island.
- Khi nào Flux hơn tốc độ kiểu Pinia? Bạn cần nhật ký action, time-travel, hoặc middleware cả org.
Tóm tắt: Đi một luồng quyết định, đừng chọn thư viện yêu thích. Ephemeral → useState. Share được → URL. Identity tin cậy → cookie. Remote/cũ → Query hoặc RSC. DI ít đổi → Context. Đồ thị client tần suất cao → store với selector. Đẩy state xuống đến khi sibling cần.
Vì sao quan trọng: “Khi nào Context đủ?” và “sao không một React Context cho cả app?” đi kèm giỏ trong Context. Họ muốn bạn chuyển giỏ và nêu rõ cái không chuyển (catalog item ở lại Query). Xem Context khác provide.
- Cách hoạt động: 1. Một component / cây ngắn, ephemeral →
useState/useReducer. 2. Share được / nút Back / bookmark → URL. 3. Identity tin cậy / session → cookie + server. - 4. Data remote có thể cũ → TanStack Query / SWR (hoặc RSC cho document). 5. DI ít đổi (theme, locale, flag,
QueryClient) → Context, tách provider. 6. Workflow client tần suất cao hoặc phức tạp → Zustand / RTK / Jotai với selector. - Đẩy state xuống đến khi sibling cần. Đừng lift cho tiện. Nếu shell sáng khi bạn gõ, tầng sai. Analog Vue:
providethì track; Context thì broadcast. Tách ra. Catalog không chuyển khi giỏ chuyển.
// Giỏ trong Context là snippet họ đưa. Catalog không chuyển.
// ❌ <Shop.Provider value={{ cart, setCart, items, setItems, draft }}>
// ✅ catalog ở lại Query; giỏ chuyển sang Zustand; draft ở lại local
export function Shop() {
const { data: items } = useQuery({ queryKey: ['catalog'], queryFn: api.catalog })
const add = useCart((s) => s.add)
const [draft, setDraft] = useState('')
return (
<>
<input value={draft} onChange={(e) => setDraft(e.target.value)} />
<ul>{items?.map((it) => (
<li key={it.id}><button onClick={() => add(it.id)}>{it.title}</button></li>
))}</ul>
</>
)
}Điều gì xảy ra: Gõ draft thì re-render Shop, không phải cả cây của một Context quá nặng. Thêm vào giỏ thì cập nhật subscriber Zustand. Catalog ở lại Query. Filter lẽ ra nằm ở searchParams, không phải setItems.
| Nếu… | Thì |
|---|---|
| Một component / cây ngắn, ephemeral | useState / useReducer |
| Share được / nút Back / bookmark | URL |
| Identity tin cậy / session | Cookie + server |
| Data remote có thể stale | TanStack Query / SWR (hoặc RSC) |
| DI ít đổi vs đồ thị tần suất cao | Context (tách) vs store + selector |
Góc nhìn senior
memokhông skip consumer của Context khivalueđổi. Tách Context state khỏi Context dispatch, hoặc thôi.- Đẩy state xuống đến khi sibling cần. Lift “phòng khi” là cách layout phải gánh từng phím gõ.
- Giỏ khách vs catalog: hai layer. Đừng chuyển catalog khi chuyển giỏ.
providecủa Vue không phải Context. Context thì broadcast; provide thì track. Nói thành lời chỗ này.- 22.1 là bản đồ. Topic này là lần đi theo thứ tự.
Lỗi hay gặp
- Triệu chứng: shell sáng khi bạn gõ. Nguyên nhân: draft trong Context / store. Cách xử lý:
useStatelocal.- Triệu chứng: một Context cho cả app. Nguyên nhân: cho tiện. Cách xử lý: tách provider; chuyển slice nóng.
- Triệu chứng: catalog copy vào Zustand “để giỏ đọc được”. Nguyên nhân: thêm một owner. Cách xử lý: Query trong component.
- Triệu chứng:
memotrên consumer Context “để chặn rerender”. Nguyên nhân:valuemới mỗi render. Cách xử lý: tách hoặc store.
Nhà tuyển dụng hay hỏi gì
Khi nào Context là đủ, và sao không dùng một React Context cho cả app? Họ đưa một giỏ trong Context rồi bảo bạn chuyển nó, và nêu rõ cái gì không chuyển theo.
Trả lời khi phỏng vấn
Context đủ cho theme, locale, và inject QueryClient. Mình tách provider, không nhét từng phím gõ vào đó. Giỏ cập nhật thường xuyên thì chuyển sang Zustand với selector; catalog item ở lại Query. Filter để trên URL. Nếu shell sáng khi mình gõ, layer đã sai. Mình đẩy state xuống đến khi sibling cần. provide của Vue thì track; Context thì broadcast. Luồng quyết định là sáu hàng if-then, không phải thư viện yêu thích. 22.1 là bản đồ; đây là lần đi.
Nhớ nhanh
- Context = DI ít đổi. Store + selector = state client tần suất cao.
- Đẩy state xuống đến khi sibling cần. Đừng lift cho tiện.
- URL thắng filter. Query chịu trách nhiệm list. Đừng copy cái nào sang store.
- Chuyển giỏ không chuyển catalog.
Câu hỏi nối tiếp thường gặp
- Refactor giỏ Context: cái gì không chuyển? Catalog (Query), filter (URL), session (cookie).
- Vì sao memo không skip consumer của Context khi value đổi? Consumer đã subscribe value đó.
- Tách Context state khỏi Context dispatch? Khi state đổi liên tục và dispatch ổn định. Hoặc cứ dùng store.
- Giỏ khách vs catalog? Store vs Query. Hai layer.
Tóm tắt: Server state là async, stale, gắn key, và share được. TanStack Query / SWR đã lo contract đó. queryKey là identity của cache (gồm cả filter). Sau POST: invalidateQueries. staleTime là độ tươi; gcTime là data không dùng còn nằm trong memory bao lâu (trước đây cacheTime). Đừng copy data của Query vào Zustand.
Vì sao quan trọng: “Sao không useEffect + useState cho list user? Sao không Redux?” Họ xem queryKey có gồm filter không, và bạn invalidate gì sau POST. Trên Next, ghép revalidateTag khi cây RSC cũng hiện data đó. Xem Server Actions.
- Cách hoạt động:
useQuery({ queryKey: ['users', filter], queryFn })là một ô cache cho mỗi filter.staleTime(ví dụ 30_000) nghĩa là “đừng refetch trong 30s”.gcTimenghĩa là “quên ô này khi không còn observer”. POST dùnguseMutationrồiinvalidateQueries({ queryKey: ['users'] })(khớp prefix). - Trong Next: RSC lo first paint của document; Query lo tương tác client trên data đó (pagination không được làm rơi shell, polling, hàng optimistic). Sau khi ghi,
invalidateQueriesvàrevalidateTagnếu cả hai cây đều hiện. Đừng chạy SWR và Query cùng lúc. - Tương đương Vue:
useAsyncData/useFetchkèmrefreshNuxtData. Chỉ khi có lý do mạnh mới để list API trong store: replication offline-first, CRDT cộng tác. Lúc đó bạn không phải cache. Query đã abort sẵn; effect tự viết phải dùngAbortController.
// queryKey là identity của cache: gồm cả filter. Invalidation là contract lúc ghi.
const filter = searchParams.get('role') ?? 'all'
const { data } = useQuery({
queryKey: ['users', filter],
queryFn: () => api.users(filter),
staleTime: 30_000, // tươi 30s; gcTime (trước đây cacheTime) là eviction
})
const qc = useQueryClient()
const { mutate } = useMutation({
mutationFn: createUser,
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
})
// Đừng: setUsers trong Zustand lúc onSuccess. Đừng: queryKey: ['users'] cho mọi filter.
// Next + RSC: cũng revalidateTag('users') từ Server Action.Điều gì xảy ra: Role admin và role all là hai ô cache. Tạo user thì invalidate mọi prefix users, nên cả hai ô đều refetch. Copy data vào Zustand sẽ tạo owner thứ ba, đã stale. staleTime tránh refetch lúc remount trong 30s; gcTime là cơ chế khác.
| Vấn đề | Vue / Nuxt | React |
|---|---|---|
| Data async được cache | useAsyncData / useFetch | TanStack Query / SWR |
| Invalidate sau khi ghi | refreshNuxtData | invalidateQueries + có thể revalidateTag |
| Cache gắn key | Chuỗi key | Mảng queryKey |
| staleTime vs gcTime | ttl / getCachedData | độ tươi vs eviction khi không dùng |
Góc nhìn senior
queryKey: ['users']cho mọi filter là một ô. Userrole=adminthấyallđến khi một lần refetch tình cờ.- Đừng copy
datacủa Query vào Zustand lúc success. Đó là cache thứ hai bạn sẽ không invalidate. - RSC lo first paint của document; Query lo tương tác live. Định nghĩa chỗ bàn giao (
HydrationBoundaryhoặc tách). - UI optimistic cần rollback.
onMutate/onErrorcủa Query, hoặcuseOptimistickèm Server Action: chọn một câu chuyện. - Vue:
refreshNuxtDatalà anh em củainvalidateQueries. Cùng layer.
Lỗi hay gặp
- Triệu chứng: filter A hiện hàng filter B. Nguyên nhân:
queryKey: ['users']. Cách xử lý: gồm filter.- Triệu chứng: list stale sau POST. Nguyên nhân: không
invalidateQueries. Cách xử lý: invalidate prefix; trên Next cũngrevalidateTag.- Triệu chứng: Zustand
userskèm Queryusers. Nguyên nhân: copydata. Cách xử lý: xóa slice.- Triệu chứng: race trên fetch
useEffecttự viết. Nguyên nhân: không abort. Cách xử lý: Query, hoặcAbortController.
Nhà tuyển dụng hay hỏi gì
Sao không useEffect và useState cho list user, và sao không Redux? Họ xem queryKey có gồm filter không, và bạn invalidate gì sau POST.
Trả lời khi phỏng vấn
Server state là async, stale, gắn key, và share được. Mình không dựng lại cái đó bằng useEffect hay một mảng users trong Redux. queryKey gồm filter. staleTime là độ tươi; gcTime là data không dùng còn nằm bao lâu. Sau POST mình invalidateQueries, và trên Next mình cũng revalidateTag nếu cây RSC hiện cùng data. Mình không copy data Query vào Zustand lúc success. RSC lo first paint của document; Query lo tương tác client trên data đó. Tương đương Nuxt là useAsyncData kèm refreshNuxtData. Abort là việc của Query; effect tự viết cần AbortController.
Nhớ nhanh
queryKeylà identity của cache. Gồm filter. Invalidate sau POST.staleTimelà độ tươi.gcTimelà eviction khi không dùng (trước đâycacheTime).- Đừng để list API trong Redux/Zustand. Đừng copy data Query vào store.
- RSC cho document; Query cho tương tác client trên data đó.
Câu hỏi nối tiếp thường gặp
- Optimistic updates vs useOptimistic + Server Actions? Một câu chuyện. Query onMutate/onError, hoặc đường Action ở 21.9.
- Abort / race? Query tự cancel. Effect tự viết phải dùng AbortController.
- queryKey users cho mọi filter? Một ô. Gồm filter trong key.
- SWR vs Query? Cùng layer. Chọn một. SWR nếu team đã dùng rồi.
Tóm tắt: App Router chuyển nguồn sự thật mặc định sang server: RSC + cookie + URL, rồi island Client nhỏ nhất. Server Component không có useState cũng không có Zustand. Persist Zustand đọc localStorage trong create() thì lệch SSR. Sau mutation: revalidateTag và/hoặc invalidateQueries, thường là cả hai.
Vì sao quan trọng: “App Router đổi state management thế nào?” User hiện tại nằm ở đâu, và cái gì gãy nếu persist Zustand đọc localStorage trong create() lúc SSR? Xem App Router và middleware.
- Cách hoạt động: Server Component fetch và render data server. Island
use clientgiữ state tương tác: island nhỏ nhất cần hook. URL (searchParams) cho UI share được. Cookie / session server cho auth. Zustand không đáng tin làm nguồn sự thật login. - Đưa DTO serialise được xuyên ranh giới RSC, không phải row ORM hay hàm (21.12). Sau mutation:
revalidatePath/revalidateTagcho cây server,invalidateQueriescho cache client, thường cả hai nếu cả hai cây hiện data. - Tương đương Nuxt:
useAsyncData+useCookie+refreshNuxtData+ island client nhỏ.useStatecủa Nuxt được share lúc SSR; Next thường không clone kiểu đó: cookie + RSC + island nhỏ. Persist/hydrate thì cẩn thận, hoặc bỏ.
// app/cart/page.tsx: Server Component. Không useState. Không Zustand ở đây.
export default async function CartPage() {
const session = await getSession() // cookie: nguồn sự thật login
if (!session) redirect('/login')
return <CartIsland userId={session.id} /> // chỉ id serialise được
}
// cart-island.tsx: use client
// persist() đọc localStorage trong create() cũng chạy trên server.
// HTML là [] ; first paint phía client có items → lệch hydration.
// Cách xử lý: cùng default hai phía, rồi useEffect fill / skipHydration.
// Sau Action addItem: revalidateTag('cart') và qc.invalidateQueries({ queryKey: ['cart'] }).Điều gì xảy ra: Nguồn sự thật login không bao giờ vào Zustand. Island nhận một id, không phải hàm. Persist trong create() sẽ vẽ giỏ khách trên server và giỏ đầy trên client. Một lần ghi phải refresh cả hai cây nếu Query cũng hiện giỏ.
| Nuxt | Next |
|---|---|
useState (share SSR) | Thường bỏ; cookie + RSC + island nhỏ |
useAsyncData | RSC fetch + Query tùy chọn |
refreshNuxtData | revalidatePath / revalidateTag |
| Pinia + plugin SSR | Persist/hydrate cẩn thận hoặc bỏ |
useCookie | cookies() phía server; không phải Zustand |
Góc nhìn senior
- Zustand không phải nguồn sự thật login. Cookie httpOnly kèm RSC mới là.
cookies()trong layout biến cả lần render thành dynamic. Chỉ đọc đúng chỗ cần. Xem 21.10.- Thứ không được vượt ranh giới RSC: secret, row ORM, hàm. DTO tối thiểu. Xem 21.12.
- Middleware chặn cookie vs
redirectRSC vsNavigatephía client: middleware không phải authz. Xem 21.8. - Hydrate giỏ đã persist mà không nháy HTML rỗng: cùng default, rồi fill, hoặc bỏ persist trên Next.
Lỗi hay gặp
- Triệu chứng: HTML [] rồi giỏ đầy. Nguyên nhân: persist đọc
localStoragetrongcreate(). Cách xử lý: cùng default, rồiuseEffectfill.- Triệu chứng: RSC đã login, island vẫn logged-out. Nguyên nhân: lấy Zustand làm nguồn sự thật login. Cách xử lý: cookie + truyền id.
- Triệu chứng: cả cây dynamic. Nguyên nhân:
cookies()trong layout gốc. Cách xử lý: đưa lần đọc xuống dưới.- Triệu chứng: Client vẫn cũ sau Action. Nguyên nhân: chỉ
revalidateTag. Cách xử lý: thêminvalidateQueries.
Nhà tuyển dụng hay hỏi gì
App Router đổi state management thế nào? User hiện tại nằm ở đâu, và cái gì gãy nếu persist Zustand đọc localStorage trong create() lúc SSR?
Trả lời khi phỏng vấn
App Router đưa nguồn sự thật mặc định sang server. RSC fetch document; session cookie mới là nguồn sự thật login; UI cần share thì nằm ở searchParams; island phía client là cây nhỏ nhất còn cần hook. Mình không đọc localStorage trong create lúc SSR. Sau mutation mình revalidate cây server và invalidate Query nếu cả hai đều hiện data. Zustand không phải nguồn sự thật login. Tương đương Nuxt là useAsyncData + useCookie + một island nhỏ; useState của Nuxt được share lúc SSR và mình thường không clone kiểu đó trên Next.
Nhớ nhanh
- RSC + cookie + URL trước. Island
use clientnhỏ nhất sau. - Zustand không phải nguồn sự thật login. Persist trong
create()lệch SSR. - Sau mutation:
revalidateTagvà/hoặcinvalidateQueries, thường là cả hai. - Đưa DTO qua ranh giới RSC, không phải row ORM hay hàm.
Câu hỏi nối tiếp thường gặp
- Middleware vs redirect RSC vs Navigate phía client? Middleware không phải authz. 21.8.
- Hydrate giỏ đã persist mà không HTML rỗng? Cùng default hai phía, rồi fill, hoặc bỏ persist.
- cookies() trong layout? Cả lần render thành dynamic. Đưa xuống dưới. 21.10.
- Cái gì không được đưa qua như prop? Secret, hàm, row ORM. 21.12.
Tóm tắt: Map thói quen, rồi bỏ fetch. Mutation vs action của Vuex tương ứng reducer vs thunk của RTK. “Cứ gọi action” của Pinia là Zustand. fetchOrders trong Pinia thành useQuery, không phải useOrderStore với loading và error. Port module Vuex 1:1 là cái bẫy.
Vì sao quan trọng: “Mình biết Pinia, nhìn đâu bên React?” Họ muốn bản map thói quen, rồi xem bạn bỏ những phần Query/RSC đã thay. Dựng lại state.loading / state.error cho mỗi list chính là isPending / error của Query.
- 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 cái bẫy ở hàng đó. Đừng đọc cả bảng.
- Port theo tầng mất một sprint: URL cho filter, Query cho list, flag UI còn lại để trong Zustand. Grep
fetch(trong file store sau migration: mỗi hit là một hàng bạn chưa bỏ. Plugin persist từ Vuex/Pinia nguy hiểm trên Next cho đến khi bạn có câu chuyện hydration (22.6).
// Thói quen Pinia: fetchOrders async trong store, kèm flag loading / error.
// Port React: bỏ fetch. Phần còn lại là flag UI thôi.
export function useOrders(status: string) {
return useQuery({
queryKey: ['orders', status],
queryFn: () => api.orders(status),
}) // isPending / error sống ở đây, không phải useOrderStore
}
const useOrderUi = create(() => ({ drawerOpen: false }))
// Grep fetch( trong file store sau migration. Mỗi hit là hàng bạn chưa bỏ.
// Đừng port plugin persist sang Next cho đến khi 22.6 có câu chuyện hydration.Điều gì xảy ra: useOrders là Query. Store Zustand nhỏ chỉ có drawerOpen. Loading và error không chuyển sang. Đó là chỗ bỏ fetch. useOrderStore 1:1 sẽ giữ nguyên bug cũ.
| Vue | React / Next |
|---|---|
Component ref | useState / useReducer |
computed | Derive lúc render / useMemo / Compiler |
provide / inject | Context (broadcast: hãy tách) |
| Vuex module / mutation / action | RTK slice / reducer / thunk hoặc Query |
Pinia defineStore / action async | Zustand create / action hoặc Query |
Nuxt useAsyncData / query / cookie | RSC fetch + Query / searchParams / cookies() |
Góc nhìn senior
- Bản map không 1:1 cho data fetching. Đó là cả ý của sheet này.
state.loadingmỗi list làisPendingcủa Query. Đừng port nó.- Mutation vs action thành reducer vs thunk của RTK, hoặc biến mất trong Query.
- Plugin persist Vuex trên Next là incident hydration cho đến khi giải xong 22.6.
- Mình dùng sheet này để chọn topic, không thay 22.1–22.6.
Lỗi hay gặp
- Triệu chứng:
useOrderStorevớiloadingvàerror. Nguyên nhân: port Pinia 1:1. Cách xử lý:useQuery.- Triệu chứng:
fetch(vẫn trong file store. Nguyên nhân: thói quen chưa bỏ. Cách xử lý: grep, rồi Query.- Triệu chứng: copy plugin persist sang Next. Nguyên nhân: quen tay plugin SSR của Nuxt. Cách xử lý: 22.6.
- Triệu chứng: đọc cả sáu 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.
Nhà tuyển dụng hay hỏi gì
Mình biết Pinia, nhìn đâu bên React? Họ muốn bản map thói quen, rồi xem bạn bỏ những phần Query và RSC đã thay. Port module Vuex 1:1 kể cả state.loading là cái bẫy.
Trả lời khi phỏng vấn
Mình map thói quen, rồi bỏ fetch. Chia mutation và action của Vuex là reducer và thunk của RTK; cứ gọi action của Pinia là Zustand. Store Pinia fetchOrders thành useOrders bọc useQuery, không phải useOrderStore với loading và error. Mình không port plugin persist sang Next cho đến khi có câu chuyện hydration. Port theo tầng mất một sprint: URL, Query, flag UI còn lại. Grep fetch trong file store sau migration. 22.1 đến 22.6 là phần chứng minh; sheet này là bản map.
Nhớ nhanh
- Vuex ≈ RTK. Pinia ≈ Zustand. Fetch ≈ Query / RSC, không phải store.
state.loadingmỗi list làisPendingcủa Query. Đừng port nó.- Port store 1:1 giữ nguyên bug cũ. Hãy port theo tầng.
- Grep
fetch(trong file store. Mỗi hit là hàng bạn chưa bỏ.
Câu hỏi nối tiếp thường gặp
- Migrate một store Pinia live? Tách URL / Query / flag UI còn lại. Đừng chuyển loading.
- Mutation vs action đi đâu? Reducer vs thunk của RTK, hoặc chúng biến mất trong Query.
- grep fetch trong file store? Mỗi hit là một fetch lẽ ra phải bỏ vào Query.
- Plugin persist Vuex trên Next nguyên xi? Incident hydration. Xem 22.6.
Tóm tắt: Bẫy là vi phạm tầng. Nêu rõ tầng, lỗi, và cách bạn đo. Context mỗi lần gõ, user trong Redux, fetch bằng useEffect, filter ở hai chỗ, persist lúc SSR, auth trong localStorage, let cache mức module trên server.
Vì sao quan trọng: Họ đưa một snippet rồi hỏi “bạn đã phá gì?” Câu senior không phải “Mình sẽ dùng Zustand”. Đó là vi phạm tầng, lỗi user nhìn thấy, và Profiler / payload / overlay hydration bạn sẽ mở. Tầng sai đắt hơn thiếu một useCallback. Xem checklist React.
- Cách hoạt động: Mở rộng từ snippet họ đưa. Mọi thứ trong Context → tách provider hoặc store với selector. Mọi thứ trong Redux/Zustand → list thuộc Query/RSC.
useEffectfetch không abort → race, Strict Mode chạy hai lần; ưu tiên Query. - Store mirror URL → URL thắng cho filter. Hydration → cùng default hai phía, rồi
useEffectfill; cookie theme;suppressHydrationWarningchỉ trên một node clock đã biết. Auth tronglocalStorage→ cookie/session thắng.let cachemức module trên server sống lâu sẽ leak sang user khác. - UI optimistic không rollback là lời nói dối mà lần refresh sau sẽ hủy. Đặt sáu fact: filter product, user hiện tại, giỏ, hàng toast, bài CMS, presence websocket: sáu tầng. Tương đương Vue: cùng loại vi phạm nằm ở chỗ nhét hết vào Pinia và
window.__NUXT__.
// 1. Persist lúc SSR khác client: HTML là [], first paint có items.
const useCart = create(persist(() => ({ items: [] as Item[] }), { name: 'cart' }))
// 2. Gõ draft thì mọi consumer của user cũng re-render.
// <UserContext.Provider value={{ user, setUser, draft, setDraft }}>
// 3. Cache module phía server leak sang user khác.
let cache: User | undefined // ❌ process Node sống lâu
export function getCachedUser() { return cache }
// Cách xử lý: skipHydration / cùng default rồi fill; tách Context; cache theo request.
// Đo: overlay hydration; Profiler lúc gõ; tìm user B trong body RSC.Điều gì xảy ra: Ba snippet, ba vi phạm tầng. Persist trong create() lệch HTML. Context quá nặng broadcast mọi lần gõ. let module trên Node là user B thấy user A. Mỗi cái có cách đo: overlay, Profiler, tìm payload.
| Sai | Câu đúng hơn |
|---|---|
| Mọi thứ trong Context | Tách provider hoặc selector; Profiler lúc gõ |
| List user trong Redux | Query/RSC; đếm cache bị trùng |
| Store copy URL | URL thắng; derive UI từ searchParams |
Persist trong create() / auth trong localStorage | Cùng default rồi fill; cookie httpOnly |
let cache mức module trên server | Cache theo request hoặc Query trên client |
Góc nhìn senior
- Nêu rõ layer, lỗi, và cách đo. Câu đó là câu trả lời senior.
- Compiler /
memoso với chọn store: đo trước. Xem React 20.2.8. Layer sai đắt hơn thiếuuseCallback. - Trên Next, ưu tiên server + URL + cookies trước khi thêm store global.
- Nói về incident hydration mà không đổ lỗi React: persist của bạn chạy trên server.
- Sáu chỗ đặt: filter URL, user cookie, giỏ store, toast local/store, CMS Query/RSC, presence websocket client.
Lỗi hay gặp
- Triệu chứng: trả lời bằng tên thư viện. Nguyên nhân: bỏ qua layer. Cách xử lý: layer, lỗi, cách đo.
- Triệu chứng: UI optimistic không rollback. Nguyên nhân: paint khi chưa có server. Cách xử lý:
onErrorrestore hoặc bỏ lần paint.- Triệu chứng: đổ lỗi React vì persist lệch. Nguyên nhân:
localStoragetrongcreate(). Cách xử lý: 22.6.- Triệu chứng: “bọn mình đã memo”. Nguyên nhân: sai layer. Cách xử lý: Profiler, rồi chuyển fact.
Nhà tuyển dụng hay hỏi gì
Họ đưa snippet: Context mỗi lần gõ phím, user trong Redux, fetch useEffect, filter ở hai chỗ, persist đọc localStorage lúc SSR. “Bạn đã phá gì?” Nêu rõ vi phạm layer, lỗi, và cách đo.
Trả lời khi phỏng vấn
Bẫy là vi phạm layer. Mình nêu rõ layer, lỗi, và cách mình sẽ đo. Context mỗi lần gõ phím là bug broadcast. Tách provider hoặc dùng selector, Profiler lúc gõ. User trong Redux là bài toán Query. Store copy URL thì đánh nhau với nút Back. Persist đọc localStorage trong create sẽ lệch HTML SSR. Auth trong localStorage thua XSS và thua HTML RSC đang logged-out. let cache mức module trên Node rò xuyên user. Trên Next mình ưu tiên server kèm URL kèm cookies trước khi thêm store global. Layer sai đắt hơn thiếu useCallback.
Nhớ nhanh
- Nêu rõ layer, lỗi, và cách đo. Đó là câu trả lời senior.
- Layer sai đắt hơn thiếu
useCallback. - Trên Next, ưu tiên server + URL + cookies trước khi thêm store global.
let cachemức module trên server chạy lâu là bug bảo mật.
Câu hỏi nối tiếp thường gặp
- Đặt sáu fact: filter, user, giỏ, toast, CMS, presence? URL, cookie, store, local/store, Query/RSC, websocket client.
- Compiler / memo so với chọn store? Đo trước. React 20.2.8. Layer vẫn quan trọng.
- Incident hydration mà không đổ lỗi React? Persist của bạn chạy trên server. Cùng default, rồi fill.
- UI optimistic không rollback? onError restore, hoặc đừng paint. Lần refresh sau sẽ hoàn tác hết.