Hub

Quản lý state

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.

  1. Cách hoạt động: Vuex là một store gốc với module có namespace. Bạn commit mutation hoặc dispatch action bằng string (cart/add).
  2. 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.
  3. TypeScript suy ra this của Pinia và return của setup-store. Module typed của Vuex vật với this và dispatch gõ string. DevTools vẫn chạy; Pinia bỏ yêu cầu mutation sync.
  4. 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òn mapState.
// 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 4Pinia
Cấu trúcMột store, module có namespaceNhiều store, compose lúc gọi
GhiMutation sync + actionAction (hoặc gán trực tiếp)
TypeScriptVật với this; dispatch gõ stringSuy luận dùng được
SSRcreateStore mỗi requestcreatePinia 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/nuxt là đườ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ách auth, 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: useAppStore vớ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ọi useX() 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.

  1. Cách hoạt động: User click trong component. Component gọi action Pinia (store.add(line)) hoặc Vuex dispatch('cart/add').
  2. Action ghi state reactive (Pinia) hoặc commit mutation sync (Vuex). Getter hoặc computed tính ra từ state đó.
  3. View re-render. Đó là cả vòng: component → action → state → view. Không có binding store hai chiều.
  4. 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ớiChia sẻSSR
ComponentBạn giữKhôngDễ
URLBookmark đượcCòn sau refreshTự nhiên
PiniaĐến khi bạn ghiCả appPhải hydrate (7.6)
Query / useAsyncDataTTL / refreshTheo keyPayload

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 useAsyncData kè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 trong useAsyncData, không phải Pinia.
  • Sau POST mình gọi refreshNuxtData hoặc invalidateQueries. 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: fetchProducts ghi 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ý: ref local hoặc URL replace đã 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: $subscribe mọ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 refreshNuxtData hoặc invalidateQueries. Đừng lấy store.items.push là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 filter mỗ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.

  1. 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 (ref local)?
  2. Global (Pinia hoặc Nuxt useState) cho data kiểu session: identity auth, theme, locale, cart, flag. ref local cho draft, hover, flag tạm.
  3. 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.
  4. 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/inject hơ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ạiLocalURLPinia
Modal mởMặc địnhHiếm (?modal=)Nếu nhiều trigger xa
Filter bảngKhôngMặc địnhSaved view
Draft wizardNếu một routeXấuNhiều route
User authKhôngKhôngMặ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 $reset store đó.
  • Filter, trang, và sort nằm trong route.query để support dán được link. router.push cho page=1,2,3 nhồi history; nên replace.
  • Boolean loading global bật spinner cả app. Ưu tiên pending theo query. Nuxt useState theo request, không phải Pinia-lite cho mọi thứ.

Lỗi hay gặp

  • Triệu chứng: App.vue re-render mỗi lần gõ. Nguyên nhân: storeToRefs củ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 ref của component. Cách xử lý: đưa vào route.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.push mỗi phím. Cách xử lý: debounce replace, 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? sessionStorage cho draft, không phải Pinia. Pinia sẽ rò giữa các user trên máy dùng chung.
  • provide/inject khá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.push khác replace cho filter? push nhồi history (trang 1, 2, 3). replace giữ 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ỏ.

  1. Cách hoạt động: commit('SET_USER', user) chạy mutation ngay. Mutation phải sync và đủ thuần để replay. Không được await.
  2. dispatch('fetchUser', id) chạy action. Action có thể async, gọi API, rồi commit. dispatch trả Promise của action; commit không trả gì hữu ích.
  3. 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.
  4. Trong Pinia không có commit. Action ghi this.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.

VuexPinia
Ghi synccommit một mutationThân action hoặc store.$patch
Async / I/Odispatch một actionCùng action đó, rồi action ghi
Giá trị trả vềdispatch → Promise; commit ≈ voidGiá trị trả về của action
Vì sao táchTime-travel DevToolsĐã bỏ: named action + plugin

Góc nhìn senior

  • Vuex: component dispatch. Mutation phải sync để DevTools replay được. async FETCH trong mutations là bug junior kinh điển.
  • dispatch trả về Promise của action. commit thì 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() trong setup, không phải this.$store.commit kiể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: await trong mutation. Cách xử lý: đưa I/O sang action, rồi commit.
  • Triệu chứng: caller race sau commit. Nguyên nhân: họ tưởng nhận được Promise. Cách xử lý: dispatch rồi await action.
  • 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: $patch phá 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: commit là sync; dispatch là async / I/O. Mutation không được await.
  • Tách ra vì DevTools time-travel, không phải vì JS cần hai loại hàm.
  • dispatch trả về Promise; commit thì 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.$patch từ component chấp nhận được? Flag UI một dòng. Không phải invariant nhiều field.
  • dispatch trả gì mà commit khô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.

  1. 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 đó.
  2. 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 reset cart; cart không được hydrate auth.
  3. Nếu foo import bar và bar import foo lúc setup, một trong hai là undefined lần gọi đầu. Module Vuex có namespace cũng cycle qua dispatch. Cycle là bug kiến trúc.
  4. 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 action dispose().
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óiVuexPinia
Cùng moduledispatch('add')this.add() hoặc useCartStore().add()
Module khácdispatch('cart/reset', null, { root: true })useCartStore() trong action
Trả vềPromise của actionGiá trị trả về của action
Cycleroot dispatch kiểu chuỗi giấu cycleundefined 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 ($onAction trong plugin) tách store nhưng khó trace trên whiteboard. Ưu tiên một owner có tên.
  • $onAction / Vuex subscribeAction sẽ recurse nếu hook dispatch cùng action. Guard hoặc lọc hook.
  • Gọi useXStore() ngoài setup hoặc lúc không có pinia active (module TS thuần, setTimeout sau test) thì throw. Truyền pinia hoặc gọi từ action.

Lỗi hay gặp

  • Triệu chứng: một store undefined lúc boot. Nguyên nhân: hai store import nhau lúc setup. Cách xử lý: gọi useX() 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: $reset chỉ reset state. Cách xử lý: action dispose().
  • Triệu chứng: hook $onAction lặ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: dispatch gọi action; điều phối là ai gọi ai.
  • dispatch('cart/add') có namespace map sang useCartStore().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ọi logout, 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 root dispatch thế nào? Action owner, store composer, hoặc plugin, không phải lời gọi root kiể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.

  1. Cách hoạt động: Tạo một Pinia mỗi SSR request. Thứ gì còn trong store lúc renderToString kết thúc đều có thể lọt vào payload HTML.
  2. Trên client, gắn cùng state trước first paint để hydration khớp. @pinia/nuxt lo tạo theo request kèm payload. Tự viết window.__PINIA__ thì dễ sai.
  3. 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 useAsyncData bị nhân đôi.
  4. Nuxt useState là 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.

SetupBạn làm gìRò / lệch
Vite SPAcreatePinia() một lần trong main.tsKhông có hydrate SSR
SSR tự viếtPinia mới mỗi renderToString; gửi state.valuePinia module-scope thì share user
Nuxt + @pinia/nuxtModule lo theo request + payloadVẫ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/nuxt nhé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 onMounted thì 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. markRaw rồ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 useAsyncData và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 useState nhẹ hơn cho một flag SSR; Pinia khi có behavior.

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

  • useState khác Pinia cho một flag SSR? useState nhẹ hơn và đã scoped theo request. Pinia nếu cần action hoặc DevTools.
  • Vuex replaceState map sang Pinia thế nào? Gán pinia.state.value từ 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.

  1. 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.
  2. Pinia phụ trách ý nghĩa chỉ-client: UX session, draft, selection chưa vào URL, overlay optimistic. Hai bản Product sẽ lệch.
  3. 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ũ.
  4. 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ỏiServer cachePinia
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?useAsyncDataChỉ nếu serialize (7.6)
Ví dụProduct detailCompare-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.
  • useFetch key đụ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 useAsyncData và 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ý: useAsyncData có key kèm refreshNuxtData.
  • 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.