Bài này nói về state Vue ở góc nhìn senior cho phỏng vấn: Pinia là mặc định, Vuex là thứ bạn vẫn phải nói khi gặp app legacy, và câu hỏi cốt lõi là một mảnh state nằm ở đâu: component, URL, Pinia, hay cache phía server. Dành cho engineer đã biết defineStore, giờ cần ra quyết định dưới SSR, ranh giới store, và invalidation.
Đọc 7.1–7.3 trước (Pinia vs Vuex, luồng, global vs local vs URL). Người phỏng vấn xoáy ranh giới store, SSR theo từng request, và “Pinia không phải REST cache” nhiều hơn commit vs dispatch. Rồi 7.4–7.5 chỉ khi role vẫn còn Vuex, và 7.6–7.7 cho hydration và invalidation. Bản React là Quản lý state (React). Với mỗi topic, 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: Pinia là store chính thức của Vue 3: nhiều store nhỏ, TypeScript suy luận được, không mutation. Vuex là một store có namespace, bạn vẫn phải nói được khi gặp app legacy.
Vì sao quan trọng: “Vì sao Pinia?” chỉ là câu khởi động. Họ muốn thứ tự migrate, một instance SSR theo từng request, và bạn có copy god object Vuex (một store giữ mọi field) vào useMainStore hay không.
- Cách hoạt động: Vuex là một store gốc với module có namespace. Bạn
commitmutation hoặcdispatchaction bằng string (cart/add). - Pinia là nhiều store, bạn compose lúc gọi:
useCartStore().add(line). Không có tầng mutation. Action ghi state, hoặc bạn gán thẳng nếu team giữ kỷ luật. - TypeScript suy ra
thiscủa Pinia và return của setup-store. Module typed của Vuex vật vớithisvàdispatchgõ string. DevTools vẫn chạy; Pinia bỏ yêu cầu mutation sync. - Migrate bằng cách cài Pinia cạnh Vuex. Feature mới dùng Pinia store. Tách module nào đã có ranh giới (
cart, flags). Bỏ Vuex khi không cònmapState.
// Cùng cart: module Vuex (legacy) vs store Pinia (mặc định Vue 3)
const cartModule = {
namespaced: true,
state: () => ({ lines: [] as string[] }),
mutations: {
add(state: { lines: string[] }, id: string) {
state.lines.push(id) // Vuex: chỉ mutation sync
},
},
}
export const useCartStore = defineStore('cart', {
state: () => ({ lines: [] as string[] }),
actions: {
add(id: string) {
this.lines.push(id) // Pinia: action là chỗ ghi
},
},
})Điều gì xảy ra: Cả hai đều giữ lines. Vuex cần module có namespace và một mutation sync. Pinia là một defineStore: TypeScript suy ra this.lines, không có commit. Tách đúng shape này, đừng clone root Vuex.
| Vuex 4 | Pinia | |
|---|---|---|
| Cấu trúc | Một store, module có namespace | Nhiều store, compose lúc gọi |
| Ghi | Mutation sync + action | Action (hoặc gán trực tiếp) |
| TypeScript | Vật với this; dispatch gõ string | Suy luận dùng được |
| SSR | createStore mỗi request | createPinia mỗi request (module Nuxt) |
Góc nhìn senior
- Pinia cho Vue 3 greenfield và mọi module mình đụng. Vuex 4 chỉ khi app đã Vuex-everywhere và không pause delivery được.
- Cài Pinia cạnh Vuex. Feature mới dùng Pinia. Tách module đã có ranh giới. Bỏ Vuex khi hết
mapState. - Cả hai cần một instance theo request khi SSR.
@pinia/nuxtlà đường ít hối tiếc nhất. Singleton ở module scope sẽ làm rò user. - Đừng clone root Vuex 80 field vào
useMainStore. Táchauth,ui, vàcheckoutđể subscriber giữ hẹp. store.count++trực tiếp là hợp lệ. Team cần audit log thì vẫn bọc phần ghi trong action có tên, kèm một plugin.
Lỗi hay gặp
- Triệu chứng: mọi SFC đều re-render. Nguyên nhân:
useAppStorevới 80 field. Cách xử lý: store có ranh giới vàstoreToRefs.- Triệu chứng: cart của user A nằm trong HTML của user B. Nguyên nhân: Pinia hoặc Vuex ở module scope. Cách xử lý: một instance mỗi SSR request.
- Triệu chứng: store treo lúc boot. Nguyên nhân:
useX()ở top level của setup. Cách xử lý: gọiuseX()trong action.- Triệu chứng: header nháy logged-out. Nguyên nhân: plugin persist đè payload. Cách xử lý: persist sau khi hydrate, chỉ pref UI.
Nhà tuyển dụng hay hỏi gì
Monolith Vue 2 đang chạy Vuex 3 với 40 module có namespace. Quý này bạn chuyển một slice checkout sang Vue 3. “Vì sao Pinia?” chỉ là câu khởi động: họ muốn thứ tự migrate, SSR theo từng request, và bạn có copy god object vào useMainStore hay không.
Trả lời khi phỏng vấn
Mình ship Pinia cho Vue 3 và coi Vuex là chuyện của app đã freeze. Vuex là một store với module có namespace; Pinia là nhiều store mình compose lúc gọi. TypeScript suy luận được Pinia; module typed của Vuex thì vật với this. Mình cài Pinia cạnh Vuex, feature mới dùng Pinia store, và tách module nào đã có ranh giới. Mình không copy root 80 field vào useMainStore. Ở checkout mình tách cart thành Pinia, để 39 module Vuex kia yên đến khi chúng có ranh giới. Cả hai cần một instance theo request khi SSR; plugin treo state trên singleton module sẽ làm rò user. Store Pinia rất nhẹ: nhiều store, phần public nhỏ, gọi useX() trong action để không circular-init.
Nhớ nhanh
- Pinia là mặc định của Vue 3; Vuex dành cho phỏng vấn legacy và app đã freeze.
- Migrate cạnh Vuex, theo store có ranh giới, không viết lại god object 1:1.
- SSR: một Pinia mỗi request. Plugin cache ở module scope sẽ làm rò user.
- Pinia bỏ mutation; suy luận TypeScript mới là phần thắng thực tế.
Câu hỏi nối tiếp thường gặp
- Có dùng Vuex 5 không? Pinia là successor chính thức. Đừng đặt cược app mới vào Vuex.
- Map
dispatch('cart/add')thế nào? Một Pinia store cho mỗi module Vuex. Thân mutation thành action. - Plugin persist khác cookie cho auth? Cookie httpOnly trên SSR. Persist cho pref UI phía client, không phải token.
store.$dispose()sửa gì? Test và store theo session, nếu không sẽ rò giữa các spec.
Tóm tắt: Luồng một chiều nghĩa là view yêu cầu một thay đổi, action ghi state, rồi view re-render từ state đó. Pinia bỏ bước commit thừa của Vuex. Pinia không phải REST cache.
Vì sao quan trọng: Họ cho bạn phác Flux 20 giây, rồi hỏi row product, thanh filter, và toast “đã thêm” nằm ở đâu. Đổ kết quả useFetch vào Pinia để DevTools thấy được là mặc định sai.
- Cách hoạt động: User click trong component. Component gọi action Pinia (
store.add(line)) hoặc Vuexdispatch('cart/add'). - Action ghi state reactive (Pinia) hoặc commit mutation sync (Vuex). Getter hoặc computed tính ra từ state đó.
- View re-render. Đó là cả vòng: component → action → state → view. Không có binding store hai chiều.
- Vòng đời mới là ràng buộc dễ dính. List phía server sẽ stale; draft UI thì không nên stale. Hai bản
Product(query và Pinia) sẽ lệch nhau.
export const useCartStore = defineStore('cart', {
state: () => ({ lines: [] as string[] }),
actions: {
add(id: string) {
this.lines.push(id) // action ghi; view đi theo
},
},
})
// Click → action → state → view. Row ở lại trong server cache.
function onAdd(id: string) {
useCartStore().add(id)
// filters: route.query. toast: ref local, không phải Pinia
}Điều gì xảy ra: onAdd là đường ghi duy nhất. Pinia giữ id cart, không phải row product. Filter để trên route.query. Toast là ref local. Sau POST, invalidate cache phía server, đừng coi store.items.push là sự thật.
| Chỗ sống | Độ mới | Chia sẻ | SSR |
|---|---|---|---|
| Component | Bạn giữ | Không | Dễ |
| URL | Bookmark được | Còn sau refresh | Tự nhiên |
| Pinia | Đến khi bạn ghi | Cả app | Phải hydrate (7.6) |
Query / useAsyncData | TTL / refresh | Theo key | Payload |
Góc nhìn senior
- Colocate trước. Một SFC dùng
ref. Chỉ promote khi cần chia sẻ hoặc vòng đời đòi hỏi. - List server nằm trong TanStack Query hoặc Nuxt
useAsyncDatakèm key. Pinia không phải REST cache. - Filter mà URL đã mang thì để ở router. Client state kiểu session (auth, theme, cart) thì vào Pinia.
onMounted→store.fetchProducts()nhét row vào rồi để đó mãi, lại trùng với trang cũng đang fetch. Ở bảng admin mình giữ row tronguseAsyncData, không phải Pinia.- Sau POST mình gọi
refreshNuxtDatahoặcinvalidateQueries. Không dual-write Pinia làm nguồn sự thật.
Lỗi hay gặp
- Triệu chứng: giá cũ sau khi admin sửa. Nguyên nhân:
fetchProductsghi Pinia một lần. Cách xử lý: cache server có key kèm invalidation.- Triệu chứng: app shell re-render mỗi lần gõ filter. Nguyên nhân: Pinia giữ draft. Cách xử lý:
reflocal hoặc URLreplaceđã debounce.- Triệu chứng: getter gây update thừa. Nguyên nhân:
state.list.filter(...)trả array mới mỗi lần đọc. Cách xử lý: cache list suy ra.- Triệu chứng: persist ghi lúc SSR. Nguyên nhân:
$subscribemọi patch. Cách xử lý: chỉ client và debounce.
Nhà tuyển dụng hay hỏi gì
Họ cho bạn phác Flux, rồi hỏi: row bảng product, thanh filter, toast “đã thêm”, mỗi thứ nằm ở đâu? Đổ kết quả useFetch vào Pinia để DevTools nhìn được là mặc định sai.
Trả lời khi phỏng vấn
Mình colocate trước. List server nằm trong Query hoặc useAsyncData kèm key và invalidation; Pinia không phải REST cache. Filter thuộc URL thì để ở router. Pinia dành cho client state kiểu session (auth, theme, cart), không phải fetchProducts trên mọi trang. Ở bảng product admin, row ở useAsyncData; toast đã thêm là ref local; id cart là phần Pinia. Luồng là component, rồi action, rồi state, rồi view. Sau POST mình gọi refreshNuxtData hoặc invalidateQueries. Mình không lấy store.items.push làm nguồn sự thật. Hai bản Product sẽ lệch, và payload SSR vốn đã là một cache.
Nhớ nhanh
- Luồng: component → action → state → view. Pinia bỏ bước commit của Vuex.
- Colocate; dùng URL khi nó đã mang sẵn state.
- Server state → Query /
useAsyncData. Pinia không phải REST cache. - Sau POST, invalidate cache. Đừng dual-write Pinia làm nguồn sự thật.
Câu hỏi nối tiếp thường gặp
- Invalidate sau một POST trong Pinia thế nào? Gọi
refreshNuxtDatahoặcinvalidateQueries. Đừng lấystore.items.pushlàm nguồn sự thật. Xem 7.7. - Optimistic UI nằm ở đâu? Overlay flag trong component hoặc Pinia. Lỗi thì rollback. Server cache vẫn là nguồn sự thật.
- Vì sao getter
filtermỗi lần đọc lại gây update thừa? Nó trả array mới mỗi lần, nên Vue thấy giá trị dependency mới. - Khi nào list trong Pinia là hợp lý? Ý nghĩa chỉ-client, kiểu compare-tray các id, không phải entity refetch được.
Tóm tắt: State global là kiểu session: auth, theme, locale, cart. Draft form để local. Filter cần sống sót sau refresh thì để trên URL. Data server không phải state UI global.
Vì sao quan trọng: “Nhiều component cần thì nhét Pinia” là luật junior. Wizard checkout, filter bảng, toggle theme, và tooltip hover, mỗi cái cần một chỗ riêng. Nếu refresh nên reset thì nó không phải global.
- Cách hoạt động: Hỏi ba câu. Refresh có giữ không (session)? Link chia sẻ có giữ không (URL)? Chỉ component này cần (
reflocal)? - Global (Pinia hoặc Nuxt
useState) cho data kiểu session: identity auth, theme, locale, cart, flag.reflocal cho draft, hover, flag tạm. - Filter, pagination, tab đang chọn, và sort để trên URL trừ khi bí mật hoặc quá lớn. Auth: Pinia giữ user đã hydrate, không phải access token nếu token có thể để httpOnly.
- Draft Pinia chỉ dành cho wizard nhiều route, có discard tường minh và tuổi tối đa. Ưu tiên
provide/injecthơn một Pinia store nhỏ cho subtree.
const route = useRoute()
const router = useRouter()
const page = computed(() => Number(route.query.page ?? 1))
function setPage(p: number) {
// replace: click filter không được nhồi history
router.replace({ query: { ...route.query, page: String(p) } })
}
// draft local, không phải Pinia, không phải URL
const hoverSku = ref<string | null>(null)Điều gì xảy ra: Pagination nằm trong route.query, nên link và nút back dùng được. replace tránh nhồi history. Hover là ref local vì refresh nên reset. Pinia không nằm trên path này.
| Loại | Local | URL | Pinia |
|---|---|---|---|
| Modal mở | Mặc định | Hiếm (?modal=) | Nếu nhiều trigger xa |
| Filter bảng | Không | Mặc định | Saved view |
| Draft wizard | Nếu một route | Xấu | Nhiều route |
| User auth | Không | Không | Mặc định (+ cookie session) |
Góc nhìn senior
- Mình không promote state chỉ vì hai component đọc nó. Chia sẻ không giống vòng đời session.
- Auth, theme, locale, và cart là kiểu session: Pinia kèm cookie session trên SSR.
- Draft form để local, hoặc wizard store có tên kèm discard. Logout phải
$resetstore đó. - Filter, trang, và sort nằm trong
route.queryđể support dán được link.router.pushchopage=1,2,3nhồi history; nênreplace. - Boolean loading global bật spinner cả app. Ưu tiên pending theo query. Nuxt
useStatetheo request, không phải Pinia-lite cho mọi thứ.
Lỗi hay gặp
- Triệu chứng:
App.vuere-render mỗi lần gõ. Nguyên nhân:storeToRefscủa store quá nặng. Cách xử lý: tách store; subscribe một field.- Triệu chứng: “link không mở cùng view”. Nguyên nhân: filter nằm trong
refcủa component. Cách xử lý: đưa vàoroute.query.- Triệu chứng: hai tab đè wizard. Nguyên nhân: draft Pinia không có key tab. Cách xử lý: sessionStorage theo tab, hoặc chấp nhận ghi sau cùng.
- Triệu chứng: typeahead nhồi history. Nguyên nhân:
router.pushmỗi phím. Cách xử lý: debouncereplace, hoặc commit lúc submit.
Nhà tuyển dụng hay hỏi gì
Không phải “nhiều component cần thì global”. Wizard checkout, filter bảng, toggle theme, và tooltip hover: họ muốn nghe auth/theme khác form draft, và URL là nguồn sự thật cho filter. Refresh nên reset thì nó không phải global.
Trả lời khi phỏng vấn
Mình không promote state chỉ vì hai component đọc nó. Auth, theme, locale, và cart là kiểu session: Pinia kèm cookie session trên SSR. Draft form để local, hoặc wizard store có tên kèm discard. Filter, trang, và sort nằm trong route.query để link và nút back dùng được. Support dán cùng một view bảng nhờ vậy. Nếu refresh nên reset giá trị thì nó chưa bao giờ là global. Mình không nhét mọi input vào global store. Với subtree mình ưu tiên provide/inject hơn Pinia store nhỏ. Nuxt useState là global theo request, không phải Pinia-lite cho mọi thứ.
Nhớ nhanh
- Global = kiểu session. Local = draft, hover, flag tạm.
- Filter, trang, và sort thuộc URL trừ khi bí mật hoặc quá lớn.
- Refresh nên reset thì nó không phải global.
- Data server không phải state UI global. Xem 7.7.
Câu hỏi nối tiếp thường gặp
- Khôi phục form sau OAuth thế nào?
sessionStoragecho draft, không phải Pinia. Pinia sẽ rò giữa các user trên máy dùng chung. provide/injectkhác Pinia store nhỏ? Ưu tiên inject cho subtree. Pinia nếu sibling xa nhau hoặc vòng đời session.- Vì sao Nuxt
useState('x')không phải Pinia-lite? Nó là state SSR theo request cho một giá trị. Không phải store có action. router.pushkhácreplacecho filter?pushnhồi history (trang 1, 2, 3).replacegiữ một mục history cho view.
Tóm tắt: Vuex tách commit (mutation sync) khỏi dispatch (action async) để DevTools time-travel được một log thuần. Pinia không tách: action chính là chỗ ghi. Topic này nói về tách đó; 7.5 là ai gọi ai.
Vì sao quan trọng: Có người viết async FETCH trong mutations. Log gãy, race xuất hiện, và câu junior là “vì Vue bảo vậy”. Chốt senior: tách này tồn tại vì DevTools, và Pinia đã bỏ.
- Cách hoạt động:
commit('SET_USER', user)chạy mutation ngay. Mutation phải sync và đủ thuần để replay. Không đượcawait. dispatch('fetchUser', id)chạy action. Action có thể async, gọi API, rồicommit.dispatchtrả Promise của action;commitkhông trả gì hữu ích.- Component gần như luôn
dispatch. Tách tồn tại để Vuex DevTools ghi một list patch sync. JavaScript chưa bao giờ cần hai loại hàm. - Trong Pinia không có
commit. Action ghithis.user = …. Team muốn audit log thì bọc phần ghi trong named action kèm plugin. Đừng bịa tầng mutation.
// Vuex: dispatch → action → commit → mutation (sync) → state
const mutations = {
SET_USER(state: { user: User | null }, user: User) {
state.user = user // không await: DevTools replay dòng này
},
}
const actions = {
async fetchUser({ commit }: { commit: Function }, id: string) {
commit('SET_USER', await api.getUser(id))
},
}
// Pinia: action chính là chỗ ghi. Không commit.
export const useUserStore = defineStore('user', {
state: () => ({ user: null as User | null }),
actions: {
async fetchUser(id: string) {
this.user = await api.getUser(id)
},
},
})Điều gì xảy ra: Vuex cần hai hàm cho một lần ghi. dispatch trả Promise để caller await được. Pinia gộp thành một action. async trong mutation Vuex sẽ gãy log DevTools và race với commit sau.
| Vuex | Pinia | |
|---|---|---|
| Ghi sync | commit một mutation | Thân action hoặc store.$patch |
| Async / I/O | dispatch một action | Cùng action đó, rồi action ghi |
| Giá trị trả về | dispatch → Promise; commit ≈ void | Giá trị trả về của action |
| Vì sao tách | Time-travel DevTools | Đã bỏ: named action + plugin |
Góc nhìn senior
- Vuex: component
dispatch. Mutation phải sync để DevTools replay được.async FETCHtrongmutationslà bug junior kinh điển. dispatchtrả về Promise của action.committhì không. Caller cần chờ thì phải dispatch, đừng commit.- Pinia: mình không dựng lại mutation. Action ghi.
$patchổn cho one-liner, không ổn cho invariant nhiều field. - Làm lại cái tách Vuex trong Pinia chỉ là hình thức thừa, trừ khi plugin audit cần một luồng ghi có tên.
- Vuex 4 + Vue 3:
useStore()trongsetup, không phảithis.$store.commitkiểu Vue 2. Undo trong Pinia là command stack, không phải mutation log.
Lỗi hay gặp
- Triệu chứng: DevTools time-travel lệch. Nguyên nhân:
awaittrong mutation. Cách xử lý: đưa I/O sang action, rồicommit.- Triệu chứng: caller race sau
commit. Nguyên nhân: họ tưởng nhận được Promise. Cách xử lý:dispatchrồiawaitaction.- Triệu chứng: wrapper “mutations.ts” trong Pinia. Nguyên nhân: port Vuex 1:1. Cách xử lý: để action ghi; chỉ thêm plugin khi cần audit.
- Triệu chứng:
$patchphá invariant hai field. Nguyên nhân: component chỉ patch một field. Cách xử lý: named action ghi cả hai.
Nhà tuyển dụng hay hỏi gì
Câu Vuex trong 30 giây, rồi hỏi bạn còn tách vậy không. Có người viết async FETCH trong mutations. Cách kết senior: trong Pinia mình không tách mutation và action.
Trả lời khi phỏng vấn
Trong Vuex mình dispatch từ component và giữ mutation sync để DevTools time-travel được. async FETCH trong mutations là bug junior kinh điển. Commit là chỗ ghi sync; dispatch là cửa async và trả về Promise. Tách ra vì cái log, không phải vì JavaScript cần hai loại hàm. Khi nhận module có async FETCH trong mutations, việc đầu là chuyển I/O sang action, không viết lại Pinia tuần đó. Trong Pinia mình không tách: action ghi state. Cần audit log thì mình bọc chỗ ghi trong named action và plugin, không bịa ra tầng mutation. store.$patch ổn cho one-liner, không ổn cho invariant nhiều field.
Nhớ nhanh
- Vuex:
commitlà sync;dispatchlà async / I/O. Mutation không đượcawait. - Tách ra vì DevTools time-travel, không phải vì JS cần hai loại hàm.
dispatchtrả về Promise;committhì không.- Pinia: action mới là chỗ ghi. Đừng dựng lại mutation.
Câu hỏi nối tiếp thường gặp
- Vì sao mutation Vuex không async được? DevTools replay và hành vi phải đoán được, không phải “vì Vue bảo vậy”.
- Undo trong Pinia thì implement thế nào? Một command stack trong store: push lần ghi ngược. Không phải mutation log.
- Khi nào
store.$patchtừ component chấp nhận được? Flag UI một dòng. Không phải invariant nhiều field. dispatchtrả gì màcommitkhông? Promise của action (và mọi giá trị trả về).
Tóm tắt: 7.4 đã nói phần cơ bản commit khác dispatch. Topic này là dispatch có namespace, giá trị trả về, và ai gọi ai. dispatch gọi một action; cycle lúc setup mới là bug.
Vì sao quan trọng: Logout phải xóa cart, overlay UI, và session. Người phỏng vấn hỏi ai gọi ai, và chuyện gì xảy ra khi useCartStore và useAuthStore import nhau lúc setup.
- Cách hoạt động: Vuex
dispatch('cart/add')là lời gọi có namespace.dispatch('user/logout', null, { root: true })gọi sang module khác. Giá trị trả về là Promise của action đó. - Pinia không có dispatch kiểu chuỗi. Gọi
useOtherStore()trong action, không ở top level lúc setup store. Chốt một hướng gọi:authđược phép resetcart;cartkhông được hydrateauth. - Nếu
fooimportbarvàbarimportfoolúc setup, một trong hai làundefinedlần gọi đầu. Module Vuex có namespace cũng cycle quadispatch. Cycle là bug kiến trúc. - Với logout, một action owner hoặc
resetAll()rõ trong composer thắng một mạng import.$reset()không chạy teardown tùy chỉnh (websockets). Ghép với một actiondispose().
export const useAuthStore = defineStore('auth', {
state: () => ({ user: null as User | null }),
actions: {
async logout() {
await api.logout()
this.user = null
// trong action, không ở top level lúc setup
useCartStore().$reset()
useUiStore().closeAllOverlays()
},
},
})
// Vuex: dispatch('cart/reset', null, { root: true })
// dispatch trả Promise của action để logout await đượcĐiều gì xảy ra: auth giữ hướng gọi. Cart và UI được reset từ trong logout, lazily, nên không store nào bị import lúc setup. Vuex cũng dispatch một chuỗi có namespace như vậy. Cycle lúc setup sẽ để một store undefined lần gọi đầu.
| Cách nói | Vuex | Pinia |
|---|---|---|
| Cùng module | dispatch('add') | this.add() hoặc useCartStore().add() |
| Module khác | dispatch('cart/reset', null, { root: true }) | useCartStore() trong action |
| Trả về | Promise của action | Giá trị trả về của action |
| Cycle | root dispatch kiểu chuỗi giấu cycle | undefined lần useX() đầu lúc setup |
Góc nhìn senior
- Chốt một hướng gọi. Auth được phép reset cart. Cart không được hydrate auth. Composer
resetAll()là một chỗ để đọc. - Gọi
useOtherStore()trong action. Hàm setup không có side-effect. Gọi lazily thì cắt được cycle lúc init. - Domain event (
$onActiontrong plugin) tách store nhưng khó trace trên whiteboard. Ưu tiên một owner có tên. $onAction/ VuexsubscribeActionsẽ recurse nếu hook dispatch cùng action. Guard hoặc lọc hook.- Gọi
useXStore()ngoàisetuphoặc lúc không có pinia active (module TS thuần,setTimeoutsau test) thì throw. Truyềnpiniahoặc gọi từ action.
Lỗi hay gặp
- Triệu chứng: một store
undefinedlúc boot. Nguyên nhân: hai store import nhau lúc setup. Cách xử lý: gọiuseX()trong action.- Triệu chứng: test logout chỉ pass khi import cart trước. Nguyên nhân: cycle ẩn. Cách xử lý:
setActivePinia(createPinia())rồi assert mọi store theo user đã xóa.- Triệu chứng: websocket vẫn mở sau
$reset(). Nguyên nhân:$resetchỉ reset state. Cách xử lý: actiondispose().- Triệu chứng: hook
$onActionlặp. Nguyên nhân: hook dispatch cùng action. Cách xử lý: lọc theo tên hoặc một flag.
Nhà tuyển dụng hay hỏi gì
Heading trông như trivia Vuex (dispatch gọi một action). Đừng lặp 7.4. Logout phải xóa cart, overlay, và session: ai gọi ai, và chuyện gì xảy ra khi useCartStore và useAuthStore import nhau lúc setup?
Trả lời khi phỏng vấn
Mục 7.4 đã nói commit khác dispatch. Dispatch là lời gọi; action là hàm. Đó là toàn bộ khác biệt của Vuex, kể cả chuỗi có namespace và Promise trả về. Trong Pinia mình gọi store kia trong action, không lúc setup, và mình chốt một hướng: auth được phép reset cart, cart không hydrate auth. Logout nằm ở auth và reset cart cùng overlay; cart không bao giờ import auth. Hai store import nhau lúc setup thì một cái là undefined lần gọi đầu, và test nào import cart trước vẫn pass. Logout là một composer hoặc một action owner, không phải mạng import.
Nhớ nhanh
- 7.4 là cái tách. Ở đây:
dispatchgọi action; điều phối là ai gọi ai. dispatch('cart/add')có namespace map sanguseCartStore().add(). Cả hai trả về Promise.- Gọi
useOtherStore()trong action, không ở top level lúc setup. - Cycle là bug kiến trúc: chốt một hướng hoặc một composer.
Câu hỏi nối tiếp thường gặp
- Test logout đụng bốn store thế nào?
setActivePinia(createPinia()), gọilogout, assert mỗi store theo user đã trống. - Store có nên import API client? Vue Router? API client thì có, nhưng qua một hàm. Router: truyền kết quả vào, đừng import singleton.
- Auto-import có giấu cycle Pinia không? Không. Nó chỉ giấu dòng import. Xem auto-import Vue 3 (5.1.3).
- Phá cycle không dùng Vuex
rootdispatch thế nào? Action owner, store composer, hoặc plugin, không phải lời gọirootkiểu chuỗi.
Tóm tắt: Hydration SSR của store nghĩa là serialize state Pinia vào payload HTML rồi gắn lên client trước first paint. Cross-request state pollution là store còn sót của User A hiện trong HTML của User B, vì một Pinia bị dùng lại giữa các request.
Vì sao quan trọng: Cart của User A hiện trong HTML của User B. Hoặc header nháy logged-out sau khi SSR render đúng. Họ muốn instance theo request, payload, và thứ bạn không được persist.
- Cách hoạt động: Tạo một Pinia mỗi SSR request. Thứ gì còn trong store lúc
renderToStringkết thúc đều có thể lọt vào payload HTML. - Trên client, gắn cùng state trước first paint để hydration khớp.
@pinia/nuxtlo tạo theo request kèm payload. Tự viếtwindow.__PINIA__thì dễ sai. - Có trong payload: profile public đã dùng trong HTML, flag đã gate SSR, cart ids nếu header render count. Không: refresh token, full ACL matrix, list
useAsyncDatabị nhân đôi. - Nuxt
useStatelà state reactive theo request cho một giá trị. Dùng cho flag SSR nhẹ. Dùng Pinia khi có behavior (action, nhiều field, DevTools).
// ❌ pinia ở module scope: user A rò sang user B (ô nhiễm)
export const pinia = createPinia()
// ✅ một pinia mỗi request; serialize pinia.state.value vào payload
export async function renderOk(cookie: string) {
const pinia = createPinia()
const app = createSSRApp(Root)
app.use(pinia)
useAuthStore(pinia).hydrateFrom(cookie)
const html = await renderToString(app)
return { html, state: pinia.state.value }
}Điều gì xảy ra: Export sai là singleton của cả process. Hai SSR request chồng nhau sẽ share user. Cách đúng tạo Pinia mới, hydrate từ cookie của request đó, rồi gửi pinia.state.value trong payload. Cài payload đó trước first paint, không phải trong onMounted.
| Setup | Bạn làm gì | Rò / lệch |
|---|---|---|
| Vite SPA | createPinia() một lần trong main.ts | Không có hydrate SSR |
| SSR tự viết | Pinia mới mỗi renderToString; gửi state.value | Pinia module-scope thì share user |
Nuxt + @pinia/nuxt | Module lo theo request + payload | Vẫn không được nhét token vào state |
Góc nhìn senior
- Mình tạo một Pinia mỗi SSR request và để
@pinia/nuxtnhét session tối thiểu vào payload trước first paint. - Ô nhiễm giữa request là singleton dùng chung.
const pinia = createPinia()ở module scope là cách cart user A lọt vào HTML user B. - Hydrate sau
onMountedthì muộn: Vue đã hydrate DOM với giá trị mặc định. Plugin persist chạy sau khi payload được apply. - Serialize session tối thiểu và để query phụ trách list. Serialize hết: HTML phình to, dễ dính secret. Không serialize gì: nháy UI và thêm RTT.
- Date object thành string; class instance chết hoặc hydrate thành plain object.
markRawrồi đừng SSR chúng. Test round-trip payload thật, không chỉcreateTestingPinia.
Lỗi hay gặp
- Triệu chứng: cart user A trong HTML user B. Nguyên nhân: Pinia ở module scope. Cách xử lý:
createPinia()trong request handler (hoặc@pinia/nuxt).- Triệu chứng: header nháy logged-out. Nguyên nhân: plugin persist phía client đè payload. Cách xử lý: apply payload trước; persist chỉ pref UI.
- Triệu chứng: lệch hydration ở header. Nguyên nhân: store cài trong
onMounted. Cách xử lý: cài trước first paint.- Triệu chứng: HTML 2 MB. Nguyên nhân: serialize list
useAsyncDatavào Pinia. Cách xử lý: để payload Nuxt phụ trách các key đó.
Nhà tuyển dụng hay hỏi gì
Cart user A hiện trong HTML user B. Hoặc header nháy logged-out sau khi SSR render đúng. “Pinia đi từ server sang client thế nào?” Họ muốn instance theo request, payload, và thứ bạn không được persist.
Trả lời khi phỏng vấn
Mình tạo một Pinia mỗi SSR request và để @pinia/nuxt nhét session tối thiểu vào payload trước first paint. Ô nhiễm state giữa request là singleton dùng chung: createPinia ở module scope là cách cart user A lọt vào HTML user B. Mình chứng minh bằng hai SSR request chạy song song, cookie khác nhau, rồi grep HTML tìm id cart của user kia. Mình không serialize token, ACL matrix, hay list mà useAsyncData đã phụ trách. Plugin persist chạy sau khi apply payload, không thì chúng đè server state và nháy chrome logged-out. Một flag SSR thì mình ưu tiên Nuxt useState; Pinia khi có behavior.
Nhớ nhanh
- Một Pinia mỗi SSR request. Pinia module-scope làm rò user (ô nhiễm giữa request).
- Cài state đã serialize trước first paint, không trong
onMounted. - Serialize session tối thiểu: không token, dump PII, hay list 2 MB.
- Nuxt
useStatenhẹ hơn cho một flag SSR; Pinia khi có behavior.
Câu hỏi nối tiếp thường gặp
useStatekhác Pinia cho một flag SSR?useStatenhẹ hơn và đã scoped theo request. Pinia nếu cần action hoặc DevTools.- Vuex
replaceStatemap sang Pinia thế nào? Gánpinia.state.valuetừ payload trước khi mount. - Vì sao Date object và class instance làm hỏng hydration? JSON biến date thành string và bỏ method của class. Giữ data thuần.
- Plugin persist phải chạy lúc nào so với apply payload? Sau. Trước thì nó đè server state và nháy chrome logged-out.
Tóm tắt: Server cache (useAsyncData, TanStack Query) phụ trách entity refetch được với key, độ cũ, và invalidation (cố ý làm cache miss). Pinia phụ trách ý nghĩa chỉ-client. Đừng dùng Pinia làm HTTP cache.
Vì sao quan trọng: Admin sửa giá; trang product vẫn hiện giá hôm qua vì fetchProducts ghi Pinia lúc onMounted và không ai gọi refreshNuxtData. Họ muốn tách rõ và câu chuyện mutation, kể cả cart.
- Cách hoạt động: Hỏi “backend trả cái này được không?” Nếu có, đó là server state: cho nó cache key, TTL (time to live), và đường invalidation.
- Pinia phụ trách ý nghĩa chỉ-client: UX session, draft, selection chưa vào URL, overlay optimistic. Hai bản
Productsẽ lệch. - Cart: list id trong Pinia (hoặc cookie) kèm product detail từ server cache. Cart Pinia nhét full product object thì thành máy giá cũ.
- Sau POST hoặc PATCH, invalidate. Đừng lấy “giữ Pinia sync” làm kiến trúc. Payload SSR đã là server cache; nhét cùng blob vào Pinia nhân đôi memory và chi phí hydration.
async function renameProduct(id: string, name: string) {
await $fetch(`/api/products/${id}`, {
method: 'PATCH',
body: { name },
})
// invalidate cache server: đừng store.items.push
await refreshNuxtData(`product:${id}`)
await refreshNuxtData('product-list')
useUiStore().lastSavedAt = Date.now() // overlay chỉ-client
}Điều gì xảy ra: PATCH tới API. Rồi cả key detail và key list được refresh. Pinia chỉ ghi lastSavedAt: overlay, không phải Product thứ hai. Invalidate một lần, mọi màn hiện tên mới.
| Câu hỏi | Server cache | Pinia |
|---|---|---|
| Backend trả cái này được không? | Có | Không (hoặc không phải source of truth) |
| Key + TTL? | Có | Hiếm |
| Cần khi HTML tắt JS? | useAsyncData | Chỉ nếu serialize (7.6) |
| Ví dụ | Product detail | Compare-tray các id |
Góc nhìn senior
- Chỉ query: data rất tươi, loading UI nhiều hơn, phải học key. Chỉ store: đơn giản, mặc định stale, SSR đau đầu. Dùng cả hai, có luật, thì scale được.
- Optimistic update thuộc cache library, hoặc overlay flag trong Pinia, không phải entity graph thứ hai. POST fail kèm cache không dùng sẽ bịa ra state thứ ba.
useFetchkey đụng nhau là bug cache, không phải lý do chuyển data sang Pinia.- Prefetch vào Pinia trong navigation guard nhân đôi việc
useAsyncDatavà payload đã làm trên trang sau. - TanStack Query trong Nuxt: payload vẫn thắng first paint. Client cache không được đè HTML SSR bằng store browser cũ.
Lỗi hay gặp
- Triệu chứng: giá hôm qua sau PATCH của admin. Nguyên nhân: list Pinia đổ một lần trong
onMounted. Cách xử lý:useAsyncDatacó key kèmrefreshNuxtData.- Triệu chứng: cart hiện SKU đã xóa. Nguyên nhân: full product object nằm trong Pinia. Cách xử lý: lưu id; đọc detail từ server cache.
- Triệu chứng: ba array phải sync tay. Nguyên nhân: dual-write Pinia và cache. Cách xử lý: mỗi sự thật một owner; invalidate sau POST.
- Triệu chứng: key
useFetchđụng nhau xuyên trang. Nguyên nhân: key generic'list'. Cách xử lý: nhét route vào key, đừng dump vào Pinia.
Nhà tuyển dụng hay hỏi gì
Admin sửa giá; trang product vẫn hiện giá hôm qua vì fetchProducts ghi Pinia lúc onMounted và không ai gọi refreshNuxtData. Câu chốt: đừng dùng Pinia làm HTTP cache. Họ muốn tách rõ và câu chuyện mutation, kể cả cart.
Trả lời khi phỏng vấn
Mình không dùng Pinia làm HTTP cache. Entity có key và TTL sống trong Query hoặc useAsyncData; Pinia giữ ý nghĩa chỉ-client: draft, selection, overlay. Cart là id trong Pinia kèm detail từ server cache; nhét full product là cách giá bị stale. Khi admin PATCH giá, mình refresh product:id và product-list, cart vẫn chỉ giữ id. Sau PATCH mình refreshNuxtData hoặc invalidateQueries. Hai bản Product sẽ lệch, và payload SSR đã là một cache. Flag optimistic sống trong Pinia; entity graph ở lại cache library.
Nhớ nhanh
- Pinia không phải HTTP cache. Invalidate sau POST.
- Cart: id trong Pinia (hoặc cookie); product detail từ server cache.
- Hai bản của một entity sẽ lệch: mỗi sự thật một owner.
- Payload SSR đã là server cache; đừng nhân đôi trong Pinia.
Câu hỏi nối tiếp thường gặp
- TanStack Query trong Nuxt: client cache khác payload? Payload thắng first paint. Đừng để cache browser cũ đè HTML SSR.
- Field cart nào vẫn giữ trong Pinia trên site e-commerce SSR? Id, số lượng, mã coupon. Title và giá product đến từ server cache.
- Map sang bản React thế nào? TanStack Query khác Zustand, cùng cách tách. Xem State Management (React).
- Optimistic update trong Pinia khác cache library? Overlay flag trong Pinia. Patch entity trong cache, để rollback là một đường code.