GAP·MAP

Pinia & Vue Router

[ middle depth ]

Option stores vs setup stores

Two ways to define a store. An option store mirrors the Options API:

export const useCart = defineStore("cart", {
  state: () => ({ items: [] }),          // arrow factory: fresh per instance
  getters: { count: (s) => s.items.length },
  actions: { add(item) { this.items.push(item); } },
});

A setup store is a function closer to <script setup>: a ref is state, a computed is a getter, a function is an action. It is more flexible (watchers, other composables, cross-store wiring), but you must return every state ref or it is not tracked, and there is no built-in $reset (option stores get one; in a setup store you write your own). Either way, state has to be a factory so each app instance and each SSR request gets its own object.

Why destructuring a store loses reactivity

A store is a reactive object, and pulling values off it disconnects them.

Wrong const { count } = useCounterStore(). count is the frozen number from that moment; the store keeps updating, the copy does not.

Right use storeToRefs for state and getters, which returns refs wired back to the store, and take actions straight off the store:

const store = useCounterStore();
const { count, doubleCount } = storeToRefs(store); // state + getters, reactive
const { increment } = store;                       // actions: destructure plainly

storeToRefs ignores methods and non-reactive properties, so passing an action through it drops the action.

When navigation guards fire

Global router.beforeEach runs on every navigation. Per-route beforeEnter runs only when entering that route. In-component beforeRouteUpdate and beforeRouteLeave handle reuse and departure. afterEach runs afterward and cannot change or cancel a navigation.

The trap: beforeEnter does not fire when only the params change (/users/2 to /users/3 stays on the same route), and the reused component does not re-run mounted. To refetch on a param change, watch it:

watch(() => route.params.id, fetchUser, { immediate: true });

Lazy-loading route components

Point the route at a function that returns a dynamic import. Vue Router loads the chunk on first entry, then caches it, which code-splits each route.

const UserDetails = () => import("./views/UserDetails.vue");

Do not wrap that in defineAsyncComponent. The route option expects the bare () => import(...), and Vue Router handles lazy route components itself. defineAsyncComponent is for lazy components rendered inside a template.

[ senior depth ]

The SSR shared-store trap

The server evaluates the module graph once and shares it across every request. So a store instance created at module scope, or one app-wide createPinia() reused across requests, becomes a cross-request singleton, and one user's state bleeds into another's first render.

Wrong "state is a factory (state: () => ({...})), so a module-level store is safe on the server." The factory only gives fresh state per pinia. A module-level store instance binds to one pinia and shares it regardless.

Right create a new createPinia() per request and app.use(pinia) on the per-request app. Inside setup, useStore() resolves the active pinia on its own. Outside setup (a server-side beforeEach, middleware) pass it explicitly: useUserStore(pinia). In async actions, make every useStore() call before the first await, so the active-pinia context survives the await boundary.

Getter caching and its one edge

Getters cache like computed: a zero-arg getter recomputes only when its reactive inputs change. A getter that returns a function for parameters does not:

getters: {
  byId: (state) => (id) => state.users.find((u) => u.id === id), // NOT cached
}

byId(3) runs the lookup on every call, because the cache holds the returned function, not the per-argument result. If that path is hot, memoize inside the getter or precompute a map keyed by id.

Navigation resolution order and guard races

Abbreviated order: beforeRouteLeave on the leaving component, global beforeEach, beforeRouteUpdate on reused components, per-route beforeEnter, resolve async route components, beforeRouteEnter on the entering component, global beforeResolve, confirm, afterEach, DOM update, then any next(vm => ...) callbacks. Guards can be async, and navigation stays pending until they resolve.

That pending window is where fast navigation breaks. A slow fetch for /users/2 can resolve after you have moved to /users/3 and overwrite the newer data with stale content. Guard against it: cancel with an AbortController, or compare the resolved id to the current route.params.id before committing (if (id !== route.params.id) return).

Fetch before vs after navigation

Fetch after: navigate immediately, fetch in the component, show a loading state. The transition is instant, but content pops in and you own the loading and empty UI. Fetch before: a beforeRouteEnter guard fetches and confirms only once data is ready, so the destination renders complete, but the user waits on the old page and needs a global progress indicator. Since beforeRouteEnter has no this, pass data through next(vm => vm.setData(post)). For rapidly changing params, fetch-after with a watch and an abort avoids the stale-overwrite race that a blocking guard invites.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Vue

was this useful?