skip to content

In Nuxt 4, what key does useState get when you omit one, what if two composables reuse a key, and which values can it hold?

level: middleimportance: should knowfreq 34%

answer

  1. the compiler injects a call-site key
  2. keys stored under a $s prefix
  3. same key, same state
  4. payload serialisation limits
  5. a payload reducer and reviver

basics

~20 s

Without a key, Nuxt's compiler appends one unique to that call site in the file. Two calls with the same key share one state, even from unrelated composables. Values must survive payload serialisation: no class instances, functions or symbols.

solid answer

~50 s

`useState` is one of Nuxt's keyed composables: if a call has no string key, a build transform appends one derived from the file and the call's position, which the docs describe as unique to the file and line. A composable that wraps `useState(() => 0)` therefore has a single call site, and all its callers share one state. With explicit keys, identity is purely the string: if `useCartCount` and `useUnreadCount` both use `'count'`, the second call finds the first one's value, skips its own initialiser, and the two badges move together, with no warning. So I namespace keys, like `'cart:count'`. Values go through payload serialisation, so plain data works but class instances fail with `Cannot stringify arbitrary non-POJOs` unless a payload plugin adds a reducer and reviver. `useState('draft', 0)` throws `NUXT_E7007`, because the initialiser must be a function.

code

ts · 8 lines
ts
// app/composables/counts.ts
// Bug: both share the key 'count', so they share one ref
export const useCartCount = () => useState('count', () => 0)
export const useUnreadCount = () => useState('count', () => 0)

// Fix: one namespaced key per feature
export const useCartItems = () => useState('cart:count', () => 0)
export const useInboxUnread = () => useState('inbox:unread', () => 0)

go deeper

for a junior

Recall that useState state is identified by its key, and that the initialiser must be a function returning the value.

for a middle

Explain the injected call-site key, why a wrapped useState is shared by all callers, and the serialisation limits on values.

for a senior

Prevent silent key collisions across teams with namespaced keys owned by one composable, and handle class values with payload reducers.

for a principal

Treat state keys as a shared namespace: set naming conventions and ownership so parallel teams cannot couple features by accident.

## Keys decide identity Every `useState` call is a lookup in one app-wide table, `nuxtApp.payload.state`. The **key** alone decides which entry a call gets: - the same key means the same ref, no matter which component or composable asks; - a different key means independent state; - keys are stored with a `$s` prefix, so `useState('counter')` lives at `payload.state.$scounter`. ## When you omit the key `useState` is on Nuxt's list of **keyed composables**. When a call has no string key, a build-time transform appends one: a short hash derived from the file and the call's position in it, which the docs describe as unique to the file and line number. Consequences: 1. Two `useState(() => 0)` calls in two different files get two keys, and so two separate states. 2. A composable that calls `useState(() => 0)` in its body has **one** call site. Every component that calls the composable shares that one state, which is usually what a shared counter wants. 3. The server and client builds of the same file compute the same key, which is what lets the browser find the server's value. 4. The transform recognises the call by name, so do not name your own function `useState`; the docs call it a reserved name. Explicit keys are still the better habit for shared state: they are readable in a payload dump, and they do not change when code moves around a file. ## When two composables collide In the team app, `useCartCount()` and `useUnreadCount()` were written separately, and both call `useState('count', () => 0)`: | Step | Effect | |---|---| | the first call runs | creates `$scount` with its initialiser | | the second call runs | finds `$scount`, **skips its own initialiser**, returns the same ref | | the cart increments | the unread badge changes too | Nothing warns: sharing by key is the feature working as designed. Namespace keys by feature, such as `'cart:count'` and `'inbox:unread'`, and keep each key inside the one composable that owns it. ## What values can be stored The state travels to the browser in the serialised payload, so its value must survive that serialisation: - plain data (strings, numbers, booleans, arrays, plain objects) is fine; - the docs also list `ref`, `reactive`, `shallowRef`, `shallowReactive` and `NuxtError` as supported payload types; - **class instances, functions and symbols** are not. A class instance fails with `Cannot stringify arbitrary non-POJOs`. For a class you must keep, register a payload plugin with `definePayloadPlugin`, using `definePayloadReducer` to turn the instance into data on the server and `definePayloadReviver` to rebuild it in the browser. Otherwise store the plain fields and construct the object where it is used. ## Keys with parameters State per entity needs a key per entity. A quantity selector on a product card can use `` useState(`qty:${productId}`, () => 1) ``, so each product keeps its own quantity while every component showing that product shares it. Two rules keep this safe: - build the key only from values that are identical on server and client, such as ids from the route or from fetched data; a key containing a timestamp or a random number would never match the server's entry; - keep the key format in one composable, such as `useQuantity(productId)`, so no second feature invents a slightly different spelling. Parameterised keys also make `clearNuxtState` more useful: a filter function such as `key => key.startsWith('qty:')` clears every quantity at once. ## Argument mistakes Nuxt catches - `useState('draft', 0)` throws `NUXT_E7007`: the initialiser must be a function, as in `useState('draft', () => 0)`. - A key that is not a string throws `NUXT_E7009`. ## Resetting state `clearNuxtState` takes one key, a list of keys, or a filter function, and with no argument clears every `useState` key. In Nuxt 4 it **deletes** entries by default, so the ref reads `undefined` until an initialiser runs again. Passing `{ reset: true }`, or setting `experimental.defaults.useState.resetOnClear`, resets entries to their initialisers' values instead. That becomes the default under `compatibilityVersion: 5`.

  • Why is useState(() => 0) inside a useCounter composable shared by every caller?
    The injected key depends on the call site, and inside the composable there is exactly one call site, whatever component calls `useCounter()`. Every caller therefore resolves to the same key and the same ref. To give each caller its own state, the composable would need a key parameter, such as `` useState(`counter:${id}`, () => 0) ``.
  • How can you inspect which useState keys exist at runtime?
    `useNuxtApp().payload.state` holds them, each prefixed with `$s`, so `useState('cart:count')` shows up as `$scart:count`. Auto-generated keys appear as hashes, which is one more reason to give shared state readable explicit keys.

saying these in an interview costs you the question

  • Without a key, useState creates new state on every call
  • Nuxt throws when two composables use the same useState key
  • A reused key runs each composable's initialiser separately
  • useState can hold class instances and functions as-is
  • useState('draft', 0) creates a ref that starts at 0
  • Naming your own helper useState is harmless