Nuxt provides useState, an SSR-safe composable for sharing reactive state across components. For complex state, you add Pinia as a module. The key rule: never use plain ref or reactive at module scope in Nuxt, because that state leaks between requests on the server.
useState
useState creates a keyed reactive reference that is serialized from server to client during SSR:
const count = useState('count', () => 0)The first argument is a unique key. The second is a factory function that returns the initial value. Any component that calls useState('count') gets the same reactive reference.
<!-- components/Counter.vue -->
<script setup>
const count = useState('count', () => 0)
</script>
<template>
<button @click="count++">Count: {{ count }}</button>
</template>Shared state composables
Wrap useState in composables for type safety and reuse:
// composables/useUser.ts
export function useUser() {
return useState<User | null>('user', () => null)
}<!-- Any component — same state instance everywhere -->
<script setup>
const user = useUser()
</script>Why not just use ref?
A ref declared at module scope is shared across ALL requests on the server. User A's data leaks into User B's response.
// composables/useBad.ts
const user = ref(null) // WRONG — shared across requests on the server
export function useBad() {
return user
}// composables/useGood.ts
export function useGood() {
return useState('user', () => null) // each request gets its own instance
}useState is scoped per request on the server and serialized to the client via the Nuxt payload, so state transfers cleanly without double-fetching.
Initializing state with async data
Use callOnce to run initialization logic only once (on server during SSR, never replayed on client):
<script setup>
const config = useState('config')
await callOnce(async () => {
config.value = await $fetch('/api/config')
})
</script>Clearing state
clearNuxtState('count')
clearNuxtState(['count', 'user'])
clearNuxtState() // clears everythingPinia in Nuxt
For complex state with actions, getters, and devtools integration, add Pinia:
npx nuxi module add piniaStores work like regular Pinia, but are auto-imported from stores/:
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
function addItem(product: Product) {
const existing = items.value.find(i => i.id === product.id)
if (existing) {
existing.qty++
} else {
items.value.push({ ...product, qty: 1 })
}
}
return { items, total, addItem }
})<script setup>
const cart = useCartStore()
</script>
<template>
<p>Total: {{ cart.total }}</p>
</template>useState vs Pinia
| useState | Pinia | |
|---|---|---|
| Setup | Built-in, zero config | Requires @pinia/nuxt module |
| State shape | Single value per key | Grouped state + getters + actions |
| DevTools | Basic | Full time-travel debugging |
| SSR safe | Yes | Yes (with Nuxt module) |
| Best for | Simple shared values (locale, theme, current user) | Complex domains (cart, auth, multi-step forms) |
Serialization limits
useState values are serialized to JSON when transferring from server to client. You cannot store functions, class instances, symbols, or circular references:
useState('fn', () => () => {}) // will break
useState('data', () => ({ name: 'Vue' })) // works fine