skip to content

In Nuxt 4, why does a counter seeded with a random value in a plain ref mismatch after hydration, while useState keeps the server's value?

level: middleimportance: must knowfreq 48%

answer

  1. setup runs on both sides
  2. payload.state travels with the HTML
  3. initialiser skipped if value exists
  4. one Nuxt app per request
  5. shared by key across components

basics

~20 s

A plain ref(Math.random()) is re-created when setup runs again in the browser, so the client value differs from the server HTML. useState stores its value in the Nuxt payload; during hydration it finds that value and skips the initialiser, so both sides match.

solid answer

~40 s

In a server-rendered Nuxt 4 page, `setup` runs twice: once on the server to render HTML and again in the browser to hydrate it. A plain `ref(Math.round(Math.random() * 100))` computes a new number in the browser, so Vue finds a different value from the one in the HTML, reports a hydration mismatch and patches it, and the visitor sees it flicker. `useState('counter', init)` stores the value in `nuxtApp.payload.state`, which is serialised into the server response. In the browser the call finds that value and skips `init`, so the first client render matches. The same key returns the same ref in every component, and because the server creates a fresh Nuxt app per request, it doesn't leak between visitors the way a module-level ref would.

code

ts · 3 lines
ts
// app/composables/useCounter.ts
export const useCounter = () =>
  useState('counter', () => Math.round(Math.random() * 100))

go deeper

for a junior

Recall that useState(key, init) gives shared state that survives hydration, unlike a ref created in setup.

for a middle

Explain the payload round trip: init on the server, serialised state, the skipped init in the browser, and why that prevents a mismatch.

for a senior

Place state deliberately among setup refs, useState and a store, keep it request-isolated, and control lifetime with clearNuxtState.

for a principal

Set the team rule for when cross-component state stays in useState and when it graduates to a store with behaviour and devtools.

## The failing counter A team page shows a counter seeded with a random start value: 1. On the server, `setup` runs and `const n = ref(Math.round(Math.random() * 100))` gives, say, 42. The HTML says `Count: 42`. 2. In the browser, hydration runs `setup` **again**. The same line produces a new number, say 17. 3. Vue compares its first client render with the server HTML, finds `17` where the markup says `42`, reports a **hydration mismatch** and patches the text. 4. The visitor sees the number flicker, and any logic that trusted the server's value is now working with another one. Nothing carried the server's value to the browser. Only data that Nuxt deliberately places in the **payload** survives the trip. ## What useState does `useState(key, init)` is Nuxt's answer: - It stores the value in **`nuxtApp.payload.state`**, under the key with a `$s` prefix. The payload is serialised into the server's HTML response. - On the server, the first call finds nothing under the key and runs **`init`** to create the value. - In the browser, Nuxt restores the payload before hydrating. The call finds the server's value and **skips `init`**, so the first client render matches the markup. - It returns a **ref**. Writes are reactive, and every component that asks for the same key in the same app gets the **same state**. - On the server a new Nuxt app is created per request, so one visitor's `counter` never appears in another's response. - In the browser the state lives as long as the app, so it survives client-side navigation until it is cleared. ## Choosing where a counter lives | Option | Survives hydration | Shared between components | Isolated per request on the server | |---|---|---|---| | `ref()` inside `setup` | no, recomputed | no | yes | | `ref()` at module scope | no | yes | **no**, shared by all requests | | `useState('counter', init)` | yes, via the payload | yes, by key | yes | | a Pinia store | yes, through its Nuxt integration | yes | yes, one store instance per request | For small pieces of cross-component state, `useState` is the built-in choice. A store earns its place once the state has behaviour: actions, getters and devtools support. ## The shared counter, done right Wrap the call in a composable so every consumer uses one key: - `app/composables/useCounter.ts` exports `useCounter = () => useState('counter', () => 0)`; - the header badge and the page both call `useCounter()` and render the same ref; - the server's value arrives with the HTML, so both hydrate without a mismatch; - an increment in the badge updates the page at once, because it is one ref. ## Where it can be called `useState` reads the current Nuxt app, so it must run where that app is known: a component's `setup`, a Nuxt plugin or route middleware. Calling it at the top level of a module fails on the server, because no request's Nuxt app is active while the module is being evaluated. ## Pitfalls - **An initialiser with side effects** runs wherever the key is first created: usually on the server during the first request, but in the browser for a key first used after a client-side navigation. - **Non-serialisable values** such as class instances cannot travel in the payload. - **Large objects** become deeply reactive; return a `shallowRef` from `init` when deep reactivity is not needed. - **Keys are app-wide**: two features that both pick `'counter'` share one value. ## Why not generate the value in onMounted? A common workaround is to start the ref at a fixed value and assign the random number in `onMounted`. That also avoids the mismatch, because the server and the first client render agree on the fixed value, but it changes the outcome: - the server HTML never shows the real value, so crawlers and users without JavaScript see the placeholder; - the number changes visibly after mount; - each component that does this gets its own number, not a shared one. `useState` keeps the value decided on the server, shows it in the HTML, and shares it everywhere by key. The `onMounted` pattern fits values that only make sense in the browser, such as the window width, and those are better not stored in server state at all.

  • Why not just declare const counter = ref(0) at module scope in app/composables/useCounter.ts?
    In the browser that would share one ref, but on the server the module is evaluated once per process, so every concurrent request would share and mutate the same counter, and its value still wouldn't travel to the browser. `useState` is tied to the per-request Nuxt app and serialised into the payload, which fixes both problems.
  • The counter must survive a client-side navigation but reset when the user logs out. How?
    `useState` values live in the browser's Nuxt app, so they persist across client-side navigation by default. On logout, call `clearNuxtState('counter')`. In Nuxt 4 that deletes the entry by default; pass `{ reset: true }` to restore the initialiser's value instead, so the ref reads 0 rather than `undefined`.

saying these in an interview costs you the question

  • A ref created in setup keeps its server value after hydration
  • useState saves the value in localStorage between page loads
  • useState state is shared by all visitors on the same server
  • The useState initialiser runs again in the browser on every hydration
  • A module-level ref is a safe per-user store in SSR