skip to content

In a Pinia plugin that adds the Vue Router instance to every store, why wrap it in markRaw(), and what breaks without it?

level: seniorimportance: should knowfreq 33%

answer

  1. the store is reactive
  2. nested objects get proxied
  3. refs inside get unwrapped
  4. currentRoute stops being a ref
  5. dev diagnostic PINIA_R1006

basics

~10 s

Every store is reactive(), so an external object put on it is read back through a reactive proxy that unwraps its refs; store.router.currentRoute.value then fails. markRaw() makes the store hand back the untouched router.

solid answer

~40 s

A Pinia store is a `reactive()` object, and reading a plain object from a reactive object returns a **reactive proxy** of it. That proxy unwraps any refs it contains, so `store.router.currentRoute` returns the route object itself instead of Vue Router's `shallowRef`, and code written as `store.router.currentRoute.value.path` throws. The proxy is also a different identity from the real router and adds tracking to a large object graph that was never meant to be reactive. Wrapping it with `markRaw(router)` marks the object so reactivity leaves it alone, and the store returns the original instance. Pinia 4 also reports the dev diagnostic `PINIA_R1006` when a plugin **returns** a plain non-reactive object without `markRaw`. Type the addition by augmenting `PiniaCustomProperties` with `router: Router`.

code

ts · 14 lines
ts
import 'pinia'
import { markRaw } from 'vue'
import type { Router } from 'vue-router'
import { createPinia } from 'pinia'
import { router } from './router'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    router: Router
  }
}

export const pinia = createPinia()
pinia.use(() => ({ router: markRaw(router) }))

go deeper

for a junior

Recall that a Pinia store is reactive, and that objects from other libraries added by a plugin should be wrapped in markRaw().

for a middle

Explain what reading an object through a reactive store does: it returns a proxy that unwraps refs, so currentRoute stops being a ref.

for a senior

Diagnose the symptoms (undefined .value, identity mismatches, private-field errors), know the PINIA_R1006 diagnostic, and type the addition with PiniaCustomProperties.

for a principal

Decide which services deserve store-wide injection at all, versus setup stores calling composables or modules importing them directly, and set that rule for the codebase.

## The store is a reactive object Pinia builds every store with `reactive()`. That is why state and getters read without `.value`: a reactive object **unwraps refs** stored on it and returns **reactive proxies** for plain objects read from it. The behaviour is exactly right for state and wrong for objects that come from other libraries. The usual external objects a plugin wants on every store are a router, an HTTP client, a toast or modal manager, or a class instance from an SDK. None of them is state; they are services. The docs rule is short: wrap class instances and other non-reactive objects in `markRaw()` before handing them to the store. ## What goes wrong without markRaw Take a plugin that does `store.router = router` with the Vue Router instance. `createRouter()` returns a plain object whose `currentRoute` is a `shallowRef`. Reading `store.router` goes through the store's proxy, so you get a reactive proxy of the router, and on that proxy: 1. **Refs are unwrapped.** `store.router.currentRoute` returns the route object, not the ref, so `store.router.currentRoute.value` is `undefined` and `store.router.currentRoute.value.path` throws a `TypeError`. The same code with the real router works. 2. **Identity changes.** `store.router === router` is false, which breaks equality checks, `WeakMap` lookups keyed by the instance, and any library that compares instances. 3. **Class internals can break.** Methods called on a proxy run with the proxy as `this`, and JavaScript private fields (`#field`) cannot be read through a proxy, so some class instances throw. 4. **Tracking cost.** Every property read inside a component render or `computed()` registers dependencies on objects that never change in a meaningful way. | Held as | `store.router.currentRoute` | `store.router === router` | Tracked | |---|---|---|---| | plain object | unwrapped route object | false | yes, deeply | | `markRaw(router)` | the `shallowRef` itself | true | no | ## The fix ```ts import { markRaw } from 'vue' import { router } from './router' pinia.use(({ store }) => { store.router = markRaw(router) }) ``` `markRaw()` sets a flag on the object that tells Vue's reactivity never to proxy it, so the store stores and returns the original instance. Its general semantics, and its cousin `toRaw()`, belong to Vue's reactivity; here the point is only that a store is reactive, so external objects need it. ## The development diagnostic Pinia 4 checks what a plugin **returns** in development builds. An object value that is not a ref, not reactive and not marked raw triggers the coded console diagnostic `PINIA_R1006`: the property is not reactive, so `storeToRefs()` ignores it, and the fix is to wrap real state in `ref()`/`reactive()` or mark an intentional service with `markRaw()`. The check looks at returned values only; a plugin that assigns `store.router = router` directly gets no warning and still suffers the problems above. ## Typing the addition The property is not on the store type until you augment `PiniaCustomProperties`: ```ts import 'pinia' import type { Router } from 'vue-router' declare module 'pinia' { export interface PiniaCustomProperties { router: Router } } ``` Now `this.router.push('/login')` in an option-store action and `store.router` in a component are typed. ## Alternatives worth naming - A **setup store** can call `useRouter()` inside its setup function, because Pinia runs setup with the app's injection context; that is the composables-in-setup-stores topic and needs no plugin. - A module can import the router directly where only one store needs it. - A plugin is the right tool when **many** stores need the same service, or when option-store actions should reach it through `this`. ## Auditing an existing plugin When reviewing a codebase that already injects services through a Pinia plugin, a few checks find the problem quickly: - Look at every value the plugin returns or assigns: anything that is not state and not a primitive or function should be wrapped in `markRaw()`. - Open the browser console in a development build: `PINIA_R1006` names the store and the key for every returned value that needs attention. - Search for reads such as `store.router.currentRoute.value` or comparisons against the imported instance; they are where the proxy shows up as a bug. - Confirm the augmentation of `PiniaCustomProperties` matches what the plugin really adds, so types do not promise a property some stores lack. ## Common mistakes - Assigning a service to the store without `markRaw()` because it seemed to work in a quick test. - Believing `markRaw()` is only a performance hint. - Wrapping real state in `markRaw()`, which silently stops it being reactive. - Typing the addition with `PiniaCustomStateProperties`, which is meant for state.

  • Does store.router.push('/login') also fail when the router was assigned without markRaw()?
    Not necessarily. Vue Router's `push` is a closure over the router's internals, so calling it through the proxy usually still navigates. That is why the bug slips through: navigation works in a quick test while reads of `currentRoute`, identity checks or class-based services fail later. The fix is still `markRaw()`.
  • Would markRaw() be a mistake for a plugin that adds a per-store loading flag?
    Yes. A flag is state that components should react to, so it needs to be a `ref()`, which the store unwraps and tracks. `markRaw()` would make it a plain value the store never tracks, and changing it would not update the view. Reserve `markRaw()` for services and foreign objects.

saying these in an interview costs you the question

  • markRaw() on a store service is only a micro-optimisation
  • A reactive proxy of the router behaves exactly like the router
  • Pinia automatically marks plugin properties as raw
  • Assigning store.router directly also triggers the PINIA_R1006 warning
  • Loading flags and counters should also be wrapped in markRaw()