---
title: "useState, ref or Pinia? State in a Nuxt app · vuejs.school"
description: "A ref outside a component leaks between requests on the server. Here is why, what useState does about it, and when a Pinia store earns its place in a Nuxt 4 app."
canonical_url: "https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt"
meta:
  "og:description": "A ref outside a component leaks between requests on the server. Here is why, what useState does about it, and when a Pinia store earns its place in a Nuxt 4 app."
  "og:image": "https://vuejs.school/_og/d/a_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcA%2Cdescription_~QSByZWYgb3V0c2lkZSBhIGNvbXBvbmVudCBsZWFrcyBiZXR3ZWVuIHJlcXVlc3RzIG9uIHRoZSBzZXJ2ZXIuIEhlcmUgaXMgd2h5LCB3aGF0IHVzZVN0YXRlIGRvZXMgYWJvdXQgaXQsIGFuZCB3aGVuIGEgUGluaWEgc3RvcmUgZWFybnMgaXRzIHBsYWNlIGluIGEgTnV4dCA0IGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VzdGF0ZS1yZWYtb3ItcGluaWEtaW4tbnV4dCI%2Cs_yMxBpKK17jvF8hud.png"
  "og:image:alt": "useState, ref or Pinia? State in a Nuxt app · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "useState, ref or Pinia? State in a Nuxt app · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcA%2Cdescription_~QSByZWYgb3V0c2lkZSBhIGNvbXBvbmVudCBsZWFrcyBiZXR3ZWVuIHJlcXVlc3RzIG9uIHRoZSBzZXJ2ZXIuIEhlcmUgaXMgd2h5LCB3aGF0IHVzZVN0YXRlIGRvZXMgYWJvdXQgaXQsIGFuZCB3aGVuIGEgUGluaWEgc3RvcmUgZWFybnMgaXRzIHBsYWNlIGluIGEgTnV4dCA0IGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VzdGF0ZS1yZWYtb3ItcGluaWEtaW4tbnV4dCI%2Cs_yMxBpKK17jvF8hud.png"
  "twitter:image:alt": "useState, ref or Pinia? State in a Nuxt app · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## Why a module-level ref leaks between requests

Here is a composable that looks harmless.

*app/composables/useCurrentUser.ts**Breaks***

```
interface User {
  id: number
  name: string
}

const currentUser = ref<User | null>(null)

export function useCurrentUser() {
  return currentUser
}
```

In the browser it works, because each visitor runs their own copy of the app. On the server, one copy of the module serves everyone. Node loads it once, so `currentUser` is created once and lives as long as the server process. A request from Alice sets it. A stranger's request a second later renders a page that reads it, and the server's HTML says "Welcome, Alice".

That is a cross-request state leak. Nuxt's [state management guide](https://nuxt.com/docs/4.x/getting-started/state-management) puts it first under best practices: never define a `ref` outside `<script setup>` or `setup()`, because the state is shared across requests on the server and can lead to memory leaks.

The fix is to ask Nuxt for state that belongs to the current request.

*app/composables/useCurrentUser.ts**Works***

```
interface User {
  id: number
  name: string
}

export const useCurrentUser = () => useState<User | null>('current-user', () => null)
```

Nothing is created at module level. Each call asks the current Nuxt app instance for the state under the key `'current-user'`, and on the server there is one Nuxt app instance per request. Alice's value never meets the stranger's.

## How useState works on the server and in the browser

`useState(key, init)` returns a `Ref`. The first call with a key runs `init` and stores the result. Every later call with the same key, in any component, gets a ref to that same value. Two things make it different from a plain `ref`.

It is scoped to the request on the server, as above. And it is carried across to the browser. When the server finishes rendering, Nuxt serialises every `useState` value into the page payload, the data Nuxt embeds in the HTML for the browser. When the browser hydrates (Vue takes over the server's HTML), `useState` with the same key starts from that value instead of running `init` again. The server and the client agree, which is exactly what hydration needs.

Serialisation sets the one hard limit: the value has to survive the trip into the page. Numbers, strings, booleans, arrays and plain objects are safe, and so are `Date`, `Map` and `Set`, which Nuxt's serialiser (the devalue library) also handles. Keep out what Nuxt's docs name as unserialisable: classes, functions and symbols. A class instance makes the serialiser throw "Cannot stringify arbitrary non-POJOs" (a POJO is a plain old JavaScript object).

For a large array or object that you replace rather than change in place, return a `shallowRef` from `init` to skip deep reactivity.

Name your keys. Nuxt can generate one from the file and line, but a named key is easier to find and to clear with `clearNuxtState('current-user')`.

## Load app-wide state once with callOnce

State that comes from an API should be fetched once on the server, not again when the browser hydrates. `callOnce` runs a function during server rendering and records in the payload that it ran, so the browser skips it. Put it in `app/app.vue`, the component that wraps every page:

*app/app.vue*

```
<script setup lang="ts">
interface SiteConfig {
  name: string
  locale: string
}

const siteConfig = useState<SiteConfig | null>('site-config', () => null)

await callOnce(async () => {
  siteConfig.value = await $fetch<SiteConfig>('/api/site-config')
})
</script>

<template>
  <NuxtPage />
</template>
```

By default `callOnce` runs once for the lifetime of the app, so the settings are fetched once each time the app loads, not on every page change. The other mode, `{ mode: 'navigation' }` as the last argument, is for a `callOnce` in a page component: the function runs again each time a client-side navigation opens that page.

Data a page needs to render, such as a product or a post, belongs in `useFetch` or `useAsyncData` instead: they already cache, deduplicate and hydrate it. `useState` with `callOnce` is for the small amount of app-wide state that many pages read and none owns: site settings, the current user, feature flags.

## Wrap useState in a composable

A bare `useState` call gets repetitive once several components need the same logic. Wrap it in a composable in `app/composables/`, where Nuxt auto-imports it.

*app/composables/useCart.ts*

```
export interface CartLine {
  id: string
  qty: number
  price: number
}

export function useCart() {
  const lines = useState<CartLine[]>('cart', () => [])
  const total = computed(() => lines.value.reduce((sum, l) => sum + l.qty * l.price, 0))

  function add(line: CartLine) {
    const existing = lines.value.find(l => l.id === line.id)
    if (existing) existing.qty += line.qty
    else lines.value.push(line)
  }

  function clear() {
    lines.value = []
  }

  return { lines, total, add, clear }
}
```

The `useState` call is inside the function, so every caller gets the current request's cart. The `computed` and the two helpers are created fresh for each component that calls `useCart()`, which is cheap and correct.

What this pattern does not give you is tooling: no timeline of changes in DevTools, no plugins for persistence, no single place for the `$fetch` call when the cart has to sync with the server. When you start wanting those, you have reached Pinia.

## When Pinia is worth it

Pinia gives each piece of state a name, a home and tooling:

- A store groups state, getters (computed values) and actions (functions that change the state, often by calling an API) in one file.
- Vue DevTools shows every store and a timeline of every change.
- Plugins can persist a store or add logging.
- `@pinia/testing` lets a component test start from a known store state.

None of that matters for a theme toggle. All of it matters once the cart talks to an API, three pages change it, and a bug report says "the total was wrong after I removed an item".

## Set up Pinia with @pinia/nuxt

Add Pinia through its Nuxt module, from the project's root:

```
npx nuxt module add pinia
```

That installs `pinia` and `@pinia/nuxt` and adds the module to `nuxt.config.ts`:

*nuxt.config.ts*

```
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})
```

If `pinia` is missing from `package.json` afterwards, install it with `npm i pinia`, as [Pinia's Nuxt guide](https://pinia.vuejs.org/ssr/nuxt.html) advises.

The module auto-imports `defineStore`, `storeToRefs` and `acceptHMRUpdate`, and every store in `app/stores/` is auto-imported by its export name. Here is the same cart as a setup store, a store written as one function that returns its state, getters and actions:

*app/stores/cart.ts*

```
import type { CartLine } from '~/composables/useCart'

export const useCartStore = defineStore('cart', () => {
  const lines = ref<CartLine[]>([])
  const total = computed(() => lines.value.reduce((sum, l) => sum + l.qty * l.price, 0))

  async function checkout() {
    await $fetch('/api/checkout', { method: 'POST', body: { lines: lines.value } })
    lines.value = []
  }

  return { lines, total, checkout }
})
```

Notice the plain `ref` inside `defineStore`. This is not the leak from the first section, because the setup function does not run at module level. Pinia runs it the first time the store is used, against a Pinia instance that `@pinia/nuxt` creates per request on the server.

Hydration works as it does for `useState`. After rendering, the module puts every store's state into the payload, and in the browser it restores that state before the components render, so the same serialisation limit applies. Return every piece of state from the setup function: Pinia's docs warn that state left out of the return value breaks SSR and DevTools.

In a component, `useCartStore()` is available without an import, and `storeToRefs` takes state and getters out of the store as refs, so they stay reactive.

*app/components/CartBadge.vue*

```
<script setup lang="ts">
const cart = useCartStore()
const { total } = storeToRefs(cart)
</script>

<template>
  <span>{{ total.toFixed(2) }}</span>
</template>
```

## Choose where each piece of state lives

Use a plain `ref` inside a component for state that belongs to that component: an open menu, a draft in a form field, a hover. Never declare one at module level in a Nuxt project, not even "just for now".

Use `useState` for state many parts of the app read and that has little logic of its own: the current user, site settings, a theme, a cart in a small shop. Wrap it in a composable so the key lives in one place, and initialise it with `callOnce` when it comes from an API.

Use `useFetch` and `useAsyncData` for server data a page renders. Do not copy their results into `useState` or a store: copying creates two sources of truth.

Use Pinia when state has behaviour: actions that call the server, getters that several pages share, a change history you need to see in DevTools, a plugin you want to add, or a test that has to start from a known state. Once you have one store, keep adding stores rather than mixing the two approaches for the same data.

**Check it stuck**3 questions from the State: useState, Pinia and cookies quiz.

[**Practice now**](https://vuejs.school/learn/nuxt/state-and-composables?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**What's new in Nuxt 4.6: nuxt/server, typed $fetch, cookie sessions and Vapor**](https://vuejs.school/community/blog/whats-new-in-nuxt-4-6) [Tutorial · 6 min read**useFetch or $fetch? Picking the right one in Nuxt**](https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-12-02","description":"A ref outside a component leaks between requests on the server. Here is why, what useState does about it, and when a Pinia store earns its place in a Nuxt 4 app.","name":"useState, ref or Pinia? State in a Nuxt app","url":"https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt","breadcrumb":{"@id":"https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-12-02","description":"A ref outside a component leaks between requests on the server. Here is why, what useState does about it, and when a Pinia store earns its place in a Nuxt 4 app.","headline":"useState, ref or Pinia? State in a Nuxt app","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcA%2Cdescription_~QSByZWYgb3V0c2lkZSBhIGNvbXBvbmVudCBsZWFrcyBiZXR3ZWVuIHJlcXVlc3RzIG9uIHRoZSBzZXJ2ZXIuIEhlcmUgaXMgd2h5LCB3aGF0IHVzZVN0YXRlIGRvZXMgYWJvdXQgaXQsIGFuZCB3aGVuIGEgUGluaWEgc3RvcmUgZWFybnMgaXRzIHBsYWNlIGluIGEgTnV4dCA0IGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VzdGF0ZS1yZWYtb3ItcGluaWEtaW4tbnV4dCI%2Cs_yMxBpKK17jvF8hud.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/usestate-ref-or-pinia-in-nuxt#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"useState, ref or Pinia? State in a Nuxt app","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcA%2Cdescription_~QSByZWYgb3V0c2lkZSBhIGNvbXBvbmVudCBsZWFrcyBiZXR3ZWVuIHJlcXVlc3RzIG9uIHRoZSBzZXJ2ZXIuIEhlcmUgaXMgd2h5LCB3aGF0IHVzZVN0YXRlIGRvZXMgYWJvdXQgaXQsIGFuZCB3aGVuIGEgUGluaWEgc3RvcmUgZWFybnMgaXRzIHBsYWNlIGluIGEgTnV4dCA0IGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VzdGF0ZS1yZWYtb3ItcGluaWEtaW4tbnV4dCI%2Cs_yMxBpKK17jvF8hud.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcCDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~dXNlU3RhdGUsIHJlZiBvciBQaW5pYT8gU3RhdGUgaW4gYSBOdXh0IGFwcA%2Cdescription_~QSByZWYgb3V0c2lkZSBhIGNvbXBvbmVudCBsZWFrcyBiZXR3ZWVuIHJlcXVlc3RzIG9uIHRoZSBzZXJ2ZXIuIEhlcmUgaXMgd2h5LCB3aGF0IHVzZVN0YXRlIGRvZXMgYWJvdXQgaXQsIGFuZCB3aGVuIGEgUGluaWEgc3RvcmUgZWFybnMgaXRzIHBsYWNlIGluIGEgTnV4dCA0IGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy91c2VzdGF0ZS1yZWYtb3ItcGluaWEtaW4tbnV4dCI%2Cs_yMxBpKK17jvF8hud.png"}]}
```