A Pinia plugin returns { hasError: ref(false) } for every store; why is hasError missing from SSR state and $reset, and how should it be added?
answer
- a property is not state
- two places to write
- store.$state plus toRef
- do not overwrite hydrated values
- PiniaCustomStateProperties for typing
basics
~20 sA returned ref is only a store property: it never enters store.$state, so SSR does not serialise it and $reset ignores it. Add real state by writing a ref to store.$state when absent, then linking store.hasError with toRef.
solid answer
~40 sWhatever a plugin returns is merged onto the store object, but the store's **state** is `store.$state`, the entry in `pinia.state` that SSR serialises, devtools shows as state and `$patch`/`$reset` operate on. So `hasError` works as a property but is invisible to all of that. The docs pattern writes it in two places: if `Object.hasOwn(store.$state, 'hasError')` is false, set `store.$state.hasError = ref(false)`; then set `store.hasError = toRef(store.$state, 'hasError')` so both accesses share one value. The check matters on the client, where hydrated state from the server must not be overwritten. Don't also return it, or devtools shows it twice. `$reset()` on an option store only reapplies `state()`, so the plugin overrides `$reset` to clear its own field. Type it with `PiniaCustomStateProperties`.
code
ts · 17 linesimport { ref, toRef } from 'vue'
import type { PiniaPluginContext } from 'pinia'
export function errorFlagPlugin({ store }: PiniaPluginContext) {
if (!Object.hasOwn(store.$state, 'hasError')) {
store.$state.hasError = ref(false)
}
store.hasError = toRef(store.$state, 'hasError')
const originalReset = store.$reset.bind(store)
return {
$reset() {
originalReset()
store.hasError = false
},
}
}go deeper
Recall that a Pinia store's state is store.$state, and that a property a plugin returns is not automatically part of it.
Explain the two-place pattern: create the ref in store.$state, then link store.hasError to it with toRef so both reads share one value.
Show why the existence check protects hydrated SSR values, why the built-in $reset misses plugin state, and how to type it with PiniaCustomStateProperties.
Treat state every store must carry as a cross-team contract: argue when a flag belongs in a plugin versus inside the few stores that need it.
## Properties and state are different things Every Pinia store has two layers: - the **store object**, a `reactive()` object carrying state keys, getters, actions and anything plugins add; - the **state**, available as `store.$state`, which is the store's entry in the root `pinia.state` tree. Only the second layer is what Pinia treats as state. It is what gets serialised when you render on the server, what devtools shows under state, and what `$patch()`, `$reset()` and `$subscribe()` operate on. | | Returned or assigned **property** | Field in `store.$state` | |---|---|---| | Readable as `store.x` | yes | yes, once linked | | Serialised with `pinia.state` for SSR | no | yes | | Shown by devtools | as a custom property | as state | | Touched by `$patch()` / `$reset()` | no | `$patch()` yes; `$reset()` only if the reset knows it | | Typed with | `PiniaCustomProperties` | `PiniaCustomStateProperties` | A plugin that returns `{ hasError: ref(false) }` creates only the left column. The value works in components, but after a server render the client starts from `false` again, and state-level tooling never sees it. ## Adding real state from a plugin The Pinia docs give a four-step pattern: 1. Check whether `store.$state` already has the key with `Object.hasOwn(store.$state, 'hasError')`. 2. If not, create a `ref(false)` and assign it to `store.$state.hasError`. The ref is created inside the plugin, so each store gets its own. 3. Link the store property to the state entry with `store.hasError = toRef(store.$state, 'hasError')`, so `store.hasError` and `store.$state.hasError` read and write the same value. 4. Do **not** also return `hasError`: devtools already shows it as state, and returning it would list it twice. ```ts import { ref, toRef } from 'vue' import type { PiniaPluginContext } from 'pinia' export function errorFlagPlugin({ store }: PiniaPluginContext) { if (!Object.hasOwn(store.$state, 'hasError')) { store.$state.hasError = ref(false) } store.hasError = toRef(store.$state, 'hasError') } ``` ## Why the existence check matters On the server, the plugin runs and creates the field, and the rendered page carries `pinia.state` to the client. On the client, the root state is restored from that payload **before** the stores are created. When the plugin runs there, `store.$state.hasError` already holds the server's value. Creating a new ref unconditionally would replace it with `false` and throw the server's answer away. The `Object.hasOwn` guard keeps the hydrated value and only fills the gap on a first creation. The same pattern works for both kinds of store. In an option store, `store.$state` is the object built from `state()`; in a setup store, it is the entry Pinia assembles from the refs the setup function returned. In both cases it is the store's slice of `pinia.state`, so a key written there is serialised, shown by devtools and reachable by `$patch()`. What differs is only the reset story, covered next. A plugin can also inspect `options` to add the field only to stores that opt in, instead of to every store in the app. ## Resetting it `$reset()` exists on option stores and rebuilds state by calling the store's `state()` function and patching the result in. A field a plugin added is not produced by `state()`, so `$reset()` leaves it unchanged. The docs fix is to return a replacement `$reset` from the plugin: ```ts const originalReset = store.$reset.bind(store) return { $reset() { originalReset() store.hasError = false }, } ``` Setup stores have no built-in `$reset()` (in development the default throws), so the same plugin can be where you give them one. ## Typing it State a plugin adds is typed by augmenting `PiniaCustomStateProperties`, which receives only the state generic: ```ts import 'pinia' declare module 'pinia' { export interface PiniaCustomStateProperties<S> { hasError: boolean } } ``` This puts `hasError` on both the store and `store.$state` in the types. Use `PiniaCustomProperties` only for plain additions that are not state. ## Other details - Changes a plugin makes while the store is being created, including a `$patch()`, happen before any component could subscribe, so those subscribers never see them. - Plugin-added state is included in `pinia.state`, so anything that snapshots root state, such as SSR serialisation, now includes it. - Keep plugin-added state small and genuinely per store; a flag every store must carry is a design commitment. ## Common mistakes - Returning a ref and assuming it is state. - Creating the ref unconditionally and wiping hydrated values. - Writing only `store.$state.hasError` and never linking `store.hasError`. - Expecting the built-in `$reset()` to clear plugin state.
- Why link store.hasError with toRef instead of assigning the same ref to both places?`store.$state.hasError` is read through the reactive state object, which unwraps the ref, so reading it back gives `false`, not the ref. `toRef(store.$state, 'hasError')` creates a ref that reads and writes that key on the state object, so the store property and the state entry always stay one value.
- Which interface types hasError, and why not PiniaCustomProperties?`PiniaCustomStateProperties<S>`: it adds the field both to the store and to `store.$state` in the types. `PiniaCustomProperties` types plain additions on the store only, so `store.$state.hasError` would stay a type error.
saying these in an interview costs you the question
- Anything a plugin returns is automatically part of store.$state
- The built-in $reset() also resets fields that plugins added to state
- The Object.hasOwn check exists only to silence a TypeScript error
- Plugin state should be both written to $state and returned for devtools
- PiniaCustomProperties is the right interface for plugin-added state