In a server-rendered Pinia setup store, a recently-viewed list read from localStorage is wiped on hydration; why, and what does skipHydrate() change?
answer
- server value arrives second
- setup stores copy initial state into refs
- mark the ref, not the store
- option stores use hydrate()
- only for state properties
basics
~20 sDuring client hydration a setup store copies the server's value into each returned ref, so the server's empty list overwrites the browser's. skipHydrate() on that ref makes Pinia skip it; option stores use a hydrate() option instead.
solid answer
~40 sA setup store runs its setup function on the client, so the `recentlyViewed` ref first reads the browser's saved list. Then, because the server shipped `pinia.state.value`, Pinia copies the server's entry into every returned ref, and the server, which has no `localStorage`, had an empty list. The browser's value is overwritten, and if the ref writes back to storage, the saved list is wiped. Returning `recentlyViewed: skipHydrate(recentlyViewed)` marks the ref so Pinia skips it during hydration; it stays state for devtools and plugins. Option stores never run `state()` when hydrated state exists, so they need the `hydrate(storeState, initialState)` option to rebuild such a value. With `@pinia/nuxt`, values marked by `skipHydrate()` are also dropped from the payload. The server still renders without the list, so that part of the page should render after mount.
code
ts · 18 linesimport { ref } from 'vue'
import { defineStore, skipHydrate } from 'pinia'
function readSavedIds(): string[] {
if (typeof window === 'undefined') return []
return JSON.parse(localStorage.getItem('recent') ?? '[]')
}
export const useRecentStore = defineStore('recent', () => {
const recentlyViewed = ref<string[]>(readSavedIds())
function add(id: string) {
recentlyViewed.value = [id, ...recentlyViewed.value.filter((x) => x !== id)].slice(0, 10)
localStorage.setItem('recent', JSON.stringify(recentlyViewed.value))
}
return { recentlyViewed: skipHydrate(recentlyViewed), add }
})go deeper
Recall that in server-rendered Pinia apps the client store starts from the server's values, which is wrong for data only the browser knows.
Explain how setup stores copy initial state into returned refs, and how option stores skip state() when hydrated state exists.
Use skipHydrate() for browser-owned setup-store state and hydrate() for option stores, and handle the remaining markup mismatch deliberately.
Classify state by owner (server, browser, both) as a design rule, so hydration opt-outs are planned rather than discovered as bugs.
## The storefront scenario A storefront keeps a **recently viewed** list in the browser's `localStorage`, so it survives reloads without an account. It is exposed through a setup store: ```ts export const useRecentStore = defineStore('recent', () => { const recentlyViewed = ref<string[]>(readSavedIds()) function add(id: string) { /* ... */ } return { recentlyViewed, add } }) ``` `readSavedIds()` returns the saved array in the browser and `[]` on the server, where there is no `localStorage`. The Pinia docs show the same pattern with a storage-bound ref from VueUse. ## What hydration does to it On the server the store is created, `recentlyViewed` is `[]`, and that value is serialised with `pinia.state.value`. On the client, before any store is used, the app restores the root state. Then: 1. The first `useRecentStore()` call runs the setup function; `recentlyViewed` reads the browser's saved list. 2. Pinia sees an initial state entry for `recent` and, for every returned ref, **copies the server value in**. 3. `recentlyViewed` becomes `[]`. If the store also writes the list back to storage whenever it changes, the saved list is now erased. The server did nothing wrong; it simply could not know the value. Hydration is right for data the server owns (products, prices, the cart from the session) and wrong for data only the browser owns. ## skipHydrate() in setup stores ```ts import { defineStore, skipHydrate } from 'pinia' return { recentlyViewed: skipHydrate(recentlyViewed), add } ``` `skipHydrate(obj)` marks the object with an internal symbol and returns it unchanged. During hydration Pinia checks each returned property with `shouldHydrate()` and **leaves marked ones alone**. Key properties: - The ref is **still state**: it is returned, it sits in `pinia.state`, devtools shows it, plugins see it. - In a hand-rolled SSR setup it is still serialised with the rest of the root state; it is simply ignored when hydrating. - With `@pinia/nuxt`, a payload reducer drops values marked by `skipHydrate()` from the payload, so they are not even sent. - It is only meaningful for state properties; getters and actions are never hydrated. The docs also mention using it for a returned object that is stateful but not really state, such as a router instance returned from a setup store. ## The option-store equivalent: hydrate() Option stores work differently. When an entry for the store already exists in `pinia.state.value`, Pinia **does not call `state()`** at all; it uses the hydrated entry. So a storage-bound value created in `state()` is never created on the client. The fix is the `hydrate` option, which Pinia calls when the store is created with an initial state: ```ts defineStore('recent', { state: () => ({ recentlyViewed: readSavedIdsRef() }), hydrate(storeState) { storeState.recentlyViewed = readSavedIdsRef() }, }) ``` | Store kind | Hydration default | Opt-out | |---|---|---| | Setup store | copies each returned ref from initial state | `skipHydrate(ref)` on the returned property | | Option store | uses the initial state instead of calling `state()` | `hydrate(storeState, initialState)` to rebuild values | ## Deciding what to mark Marking is a per-property decision about who owns the value: - **Server-owned** (products, prices, the session's cart): hydrate normally; the server's value is the truth. - **Browser-owned** (recently viewed, a locally saved preference, a draft kept in storage): mark with `skipHydrate()` in setup stores, or rebuild in `hydrate()` in option stores. - **Not state at all** (a DOM element reference, a media player instance): do not return it from the store if you can avoid it; if a setup store must return a stateful object that is not really state, `skipHydrate()` keeps Pinia from treating it as hydrated data. Writing that classification down for a store makes the opt-outs obvious in review, instead of surfacing as a bug report about lost preferences. ## The remaining mismatch `skipHydrate()` keeps the browser's value, but the server HTML was rendered with an empty list. When the client renders the list with items, the DOM differs from the server's markup. Parts of the page that depend on browser-only state should render after mount, or behind a client-only boundary; that is a Vue-core concern. ## Common mistakes - Wrapping the whole store or a getter in `skipHydrate()`; it applies to returned state properties. - Assuming `skipHydrate()` removes the property from state; it only skips hydration. - Using `skipHydrate()` in an option store, which has no returned refs to mark. - Forgetting the markup mismatch after fixing the data.
- With skipHydrate() in place, is the recently-viewed list still sent in the HTML of a hand-rolled SSR app?Yes. The ref is still returned, so it is part of `pinia.state.value`, and a hand-rolled server serialises that whole object. `skipHydrate()` only makes the client ignore it. With `@pinia/nuxt`, a payload reducer drops marked values, so Nuxt does not send them.
- Why can't an option store just use skipHydrate() inside state()?Because with hydrated state present, Pinia never calls `state()` on the client; it uses the server's entry directly. A marker inside `state()` would never be reached. The option store's hook for this is `hydrate(storeState, initialState)`, where you recreate the browser-owned value.
saying these in an interview costs you the question
- skipHydrate() removes the property from the store's state
- Option stores call state() on the client and then merge the server state
- skipHydrate() is applied to the whole store definition
- Once skipHydrate() is used, the server and client markup always match
- Getters must be wrapped in skipHydrate() to avoid being overwritten