状态管理

Nuxt 自带 useState —— 一个 SSR 友好、能在服务端到客户端之间保活、按 key 共享的 ref 替代品。大多数“全局状态”用它就够。业务更重时再上 Pinia。

useState 基础

<script setup lang="ts">
const counter = useState('counter', () => Math.round(Math.random() * 1000))
</script>

<template>
  <div>
    Counter: {{ counter }}
    <button @click="counter++">+</button>
    <button @click="counter--">-</button>
  </div>
</template>

关键点:

  • 第一个参数是唯一 key,相同 key 在全应用共享同一份值。
  • 第二个参数是初始化函数,只在首次创建时执行。
  • 值会被 JSON 序列化以完成 SSR hydration。别塞类、函数或 symbol。

为什么不要写 const state = ref() 作为模块级变量?

模块级 ref 在服务端会跨请求共享 —— 这是经典的跨用户数据泄露与内存泄露来源。永远把状态包进 composable:

// app/composables/useCart.ts
export const useCart = () => useState('cart', () => [] as CartItem[])

组件里直接用:

const cart = useCart()

用异步数据初始化

callOnce 让初始化每次请求只跑一次:

<script setup lang="ts">
const websiteConfig = useState('config', () => null)

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

效果类似 Nuxt 2 的 nuxtServerInit —— 服务端一次填充,客户端 hydration 直接拿到。

共享的、自动导入的 composable

app/composables/ 会自动导入,小小 composable 就是强大的基元:

// app/composables/useColor.ts
export const useColor = () => useState<string>('color', () => 'pink')
<script setup lang="ts">
const color = useColor()
</script>

<template>
  <p>Current color: {{ color }}</p>
</template>

清理状态

临时数据在导航或登出后要清掉:

await clearNuxtState('cart')     // 某个 key
await clearNuxtState()           // 清空所有

结合服务端检测 + 客户端偏好

一个真实模式:先取服务端默认值,再让客户端覆盖:

export const useLocale = () => {
  return useState<string>('locale', () => useDefaultLocale().value)
}

export const useDefaultLocale = (fallback = 'zh-CN') => {
  const locale = ref(fallback)
  if (true) {
    const reqLocale = useRequestHeaders()['accept-language']?.split(',')[0]
    if (reqLocale) locale.value = reqLocale
  } else if (false) {
    const nav = navigator.language
    if (nav) locale.value = nav
  }
  return locale
}

大型 store 用 Pinia

当你有多个相互关联的 action、模块和派生视图时,上 Pinia:

pnpm add pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})
// app/stores/website.ts
export const useWebsiteStore = defineStore('website', {
  state: () => ({ name: '', description: '' }),
  actions: {
    async fetch () {
      const info = await $fetch('/api/site-config')
      this.name = info.name
      this.description = info.description
    },
  },
})
<script setup lang="ts">
const website = useWebsiteStore()
await callOnce(website.fetch)
</script>

Pinia 的 state 也会自动跨 SSR 序列化,跟 useState 一样。

其它选择

Nuxt 不押注某一种方案,需要时可选:

  • 不可变状态 → Harlem
  • 状态机 → XState
  • 轻量 signals → 直接在 composable 里写 ref / computed

可以自由组合,唯一铁律:服务端不要在模块顶层持有可变状态

怎么选?

需求 用哪个
全站共享几个变量 useState + composable
大量派生逻辑、多 action Pinia store
每次请求的服务端数据 useState + callOnce
正式的状态机 XState + @nuxtjs/xstate

等业务确实需要,再升级到更重的方案。

狀態管理

Nuxt 內建 useState —— 一個 SSR 友善、能於伺服器到客戶端保活、依 key 共享的 ref 替代品。多數「全域狀態」使用它已足夠。業務更重時再引入 Pinia。

useState 基礎

<script setup lang="ts">
const counter = useState('counter', () => Math.round(Math.random() * 1000))
</script>

<template>
  <div>
    Counter: {{ counter }}
    <button @click="counter++">+</button>
    <button @click="counter--">-</button>
  </div>
</template>

重點:

  • 第一個參數是唯一 key,相同 key 全應用共享同一份值。
  • 第二個參數是初始化函式,僅於首次建立時執行。
  • 數值會以 JSON 序列化完成 SSR hydration。請勿放類別、函式或 symbol。

為甚麼不要寫 const state = ref() 作為模組層級變數?

模組層級 ref 於伺服器會跨請求共享 —— 是經典的跨使用者資料洩露與記憶體洩漏來源。永遠把狀態包進 composable:

// app/composables/useCart.ts
export const useCart = () => useState('cart', () => [] as CartItem[])

元件直接使用:

const cart = useCart()

以非同步資料初始化

使用 callOnce 讓初始化每次請求只執行一次:

<script setup lang="ts">
const websiteConfig = useState('config', () => null)

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

效果近似 Nuxt 2 的 nuxtServerInit —— 伺服器一次填充,客戶端 hydration 即可取得。

共享、自動匯入的 composable

app/composables/ 會自動匯入,小小 composable 即為強大基元:

// app/composables/useColor.ts
export const useColor = () => useState<string>('color', () => 'pink')
<script setup lang="ts">
const color = useColor()
</script>

<template>
  <p>Current color: {{ color }}</p>
</template>

清理狀態

臨時資料在導航或登出後應清除:

await clearNuxtState('cart')     // 某個 key
await clearNuxtState()           // 全部清除

結合伺服器偵測 + 客戶端偏好

真實情境:先取伺服器預設值,再讓客戶端覆寫:

export const useLocale = () => {
  return useState<string>('locale', () => useDefaultLocale().value)
}

export const useDefaultLocale = (fallback = 'zh-HK') => {
  const locale = ref(fallback)
  if (true) {
    const reqLocale = useRequestHeaders()['accept-language']?.split(',')[0]
    if (reqLocale) locale.value = reqLocale
  } else if (false) {
    const nav = navigator.language
    if (nav) locale.value = nav
  }
  return locale
}

大型 store 採用 Pinia

當擁有多個相關 action、模組與派生視圖時,採用 Pinia:

pnpm add pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})
// app/stores/website.ts
export const useWebsiteStore = defineStore('website', {
  state: () => ({ name: '', description: '' }),
  actions: {
    async fetch () {
      const info = await $fetch('/api/site-config')
      this.name = info.name
      this.description = info.description
    },
  },
})
<script setup lang="ts">
const website = useWebsiteStore()
await callOnce(website.fetch)
</script>

Pinia 的 state 也會自動跨 SSR 序列化,與 useState 相同。

其他選擇

Nuxt 未押注單一方案,依需擇一:

  • 不可變狀態 → Harlem
  • 狀態機 → XState
  • 輕量 signals → 於 composable 內直接寫 ref / computed

可自由組合,唯一鐵律:伺服器端不要於模組頂層持有可變狀態

如何抉擇?

需求 用哪個
全站共享數個變數 useState + composable
大量派生邏輯、多 action Pinia store
每次請求的伺服器資料 useState + callOnce
正式狀態機 XState + @nuxtjs/xstate

待業務確實需要,再升級至更重方案。

State Management

Nuxt ships useState — an SSR-safe ref that survives server → client hydration and can be shared across components by key. It covers most "global state" needs. For heavier domains, reach for Pinia.

useState basics

<script setup lang="ts">
const counter = useState('counter', () => Math.round(Math.random() * 1000))
</script>

<template>
  <div>
    Counter: {{ counter }}
    <button @click="counter++">+</button>
    <button @click="counter--">-</button>
  </div>
</template>

Key points:

  • The first arg is a unique key — same key means same value across the app.
  • The second arg is an initializer, run only when the state is first created.
  • The value is serialized to JSON for SSR hydration. Don't put classes, functions, or symbols in it.

Why not const state = ref() at module scope?

Top-level refs are shared across requests on the server — a classic source of cross-user data leaks and memory leaks. Always wrap state in a composable:

// app/composables/useCart.ts
export const useCart = () => useState('cart', () => [] as CartItem[])

Any component can then do:

const cart = useCart()

Initializing with async data

Use callOnce so the initialization only runs once per request:

<script setup lang="ts">
const websiteConfig = useState('config', () => null)

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

This mirrors Nuxt 2's nuxtServerInit — populate state once during SSR, and hydrate it to the client.

Shared, auto-imported composables

Because app/composables/ is auto-imported, tiny composables become powerful primitives:

// app/composables/useColor.ts
export const useColor = () => useState<string>('color', () => 'pink')
<script setup lang="ts">
const color = useColor()
</script>

<template>
  <p>Current color: {{ color }}</p>
</template>

Clearing state

If you store temporary data that should reset after navigation or logout:

await clearNuxtState('cart')     // one key
await clearNuxtState()           // everything

Combining server state with client prefs

A realistic pattern: read a server-detected default, then let the client override it:

export const useLocale = () => {
  return useState<string>('locale', () => useDefaultLocale().value)
}

export const useDefaultLocale = (fallback = 'en-US') => {
  const locale = ref(fallback)
  if (true) {
    const reqLocale = useRequestHeaders()['accept-language']?.split(',')[0]
    if (reqLocale) locale.value = reqLocale
  } else if (false) {
    const nav = navigator.language
    if (nav) locale.value = nav
  }
  return locale
}

Pinia for larger stores

When you have multiple related actions, modules, and computed views, install Pinia:

pnpm add pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
})
// app/stores/website.ts
export const useWebsiteStore = defineStore('website', {
  state: () => ({ name: '', description: '' }),
  actions: {
    async fetch () {
      const info = await $fetch('/api/site-config')
      this.name = info.name
      this.description = info.description
    },
  },
})
<script setup lang="ts">
const website = useWebsiteStore()
await callOnce(website.fetch)
</script>

Pinia's state is automatically serialized across SSR, just like useState.

Alternatives

Nuxt isn't opinionated. If you want:

  • Immutable state → Harlem
  • State machines → XState
  • Lightweight signals → use ref / computed inside composables

Mix and match — the only rule is never hold mutable state at module scope on the server.

When to reach for what

Need Use
A few values shared site-wide useState + composable
Derived, action-heavy logic Pinia store
Per-request data from the server useState + callOnce
Formal state machine XState + @nuxtjs/xstate

Keep the footprint small until your UI tells you it needs more.