Перейти к содержимому

10. Управление состоянием (useState, Pinia)

Nuxt 3 предоставляет несколько инструментов для управления состоянием. Ключевое отличие от обычного Vue — состояние должно корректно работать при SSR: сериализоваться на сервере и гидрироваться на клиенте без несоответствий.


// ❌ Обычный ref — НЕ работает для глобального состояния в SSR
const count = ref(0)
// Каждый запрос создаёт новый ref
// Состояние не передаётся с сервера на клиент
// ✅ useState — работает правильно
const count = useState('counter', () => 0)
// Уникальный ключ 'counter' — один экземпляр
// Данные сериализуются в HTML и гидрируются на клиенте

composables/useAppState.ts
export const useAppState = () => {
// Состояние с ключом — одинаково на сервере и клиенте
const user = useState<User | null>('app-user', () => null)
const theme = useState<'light' | 'dark'>('app-theme', () => 'dark')
const lang = useState<string>('app-lang', () => 'ru')
return { user, theme, lang }
}
<!-- Любой компонент — одно и то же состояние -->
<script setup>
const { user, theme } = useAppState()
</script>

Pinia — официальный стейт-менеджер Vue 3. С @pinia/nuxt она работает из коробки и корректно с SSR.

Окно терминала
npm install pinia @pinia/nuxt
nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
// Автоимпорт stores
pinia: {
storesDirs: ['./stores/**'],
}
})

stores/counter.ts
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0)
const history = ref<number[]>([])
// Getters (computed)
const doubled = computed(() => count.value * 2)
const isPositive = computed(() => count.value > 0)
// Actions
const increment = () => {
history.value.push(count.value)
count.value++
}
const decrement = () => {
history.value.push(count.value)
count.value--
}
const reset = () => {
history.value.push(count.value)
count.value = 0
}
const undo = () => {
const prev = history.value.pop()
if (prev !== undefined) count.value = prev
}
return { count, doubled, isPositive, history, increment, decrement, reset, undo }
})

stores/auth.ts
export const useAuthStore = defineStore('auth', {
// State
state: () => ({
user: null as User | null,
token: null as string | null,
loading: false,
}),
// Getters
getters: {
isLoggedIn: (state) => !!state.user,
isAdmin: (state) => state.user?.role === 'admin',
fullName: (state) =>
state.user ? \`\${state.user.firstName} \${state.user.lastName}\` : '',
},
// Actions
actions: {
async login(credentials: { email: string; password: string }) {
this.loading = true
try {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: credentials,
})
this.user = response.user
this.token = response.token
await navigateTo('/dashboard')
} finally {
this.loading = false
}
},
async logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
this.user = null
this.token = null
await navigateTo('/login')
},
async fetchUser() {
try {
this.user = await $fetch('/api/auth/me')
} catch {
this.user = null
}
}
}
})

pages/dashboard.vue
<script setup lang="ts">
const authStore = useAuthStore()
const counterStore = useCounterStore()
// Деструктуризация с storeToRefs (сохраняет реактивность!)
const { user, isLoggedIn, isAdmin } = storeToRefs(authStore)
const { count, doubled } = storeToRefs(counterStore)
// Actions можно деструктурировать напрямую
const { login, logout } = authStore
const { increment, decrement } = counterStore
</script>
<template>
<div v-if="isLoggedIn">
<h1>Привет, {{ user?.name }}!</h1>
<p v-if="isAdmin">Ты администратор 👑</p>
<div>
<p>Счётчик: {{ count }} (x2: {{ doubled }})</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
<button @click="logout">Выйти</button>
</div>
</template>

Окно терминала
npm install pinia-plugin-persistedstate
npm install @pinia-plugin-persistedstate/nuxt
nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt', '@pinia-plugin-persistedstate/nuxt']
})
stores/settings.ts
export const useSettingsStore = defineStore('settings', () => {
const theme = ref<'light' | 'dark'>('dark')
const language = ref('ru')
const sidebarOpen = ref(true)
return { theme, language, sidebarOpen }
}, {
// Сохраняем в localStorage
persist: true,
// Или с детальными настройками:
persist: {
storage: persistedState.localStorage,
pick: ['theme', 'language'], // Только эти поля
}
})

stores/products.ts
export const useProductsStore = defineStore('products', () => {
const items = ref<Product[]>([])
const loading = ref(false)
const fetchProducts = async () => {
loading.value = true
items.value = await $fetch('/api/products')
loading.value = false
}
return { items, loading, fetchProducts }
})
<!-- pages/products.vue — серверная загрузка данных в store -->
<script setup>
const store = useProductsStore()
// callOnce — выполняется один раз (и на сервере, и на клиенте)
// Данные гидрируются автоматически
await callOnce(store.fetchProducts)
</script>

stores/cart.ts
export const useCartStore = defineStore('cart', () => {
// Используем другой store внутри!
const authStore = useAuthStore()
const items = ref<CartItem[]>([])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)
const checkout = async () => {
if (!authStore.isLoggedIn) {
await navigateTo('/login')
return
}
await $fetch('/api/orders', {
method: 'POST',
body: { items: items.value }
})
items.value = []
}
return { items, total, checkout }
})

plugins/analytics.ts
export default defineNuxtPlugin(() => {
const { $pinia } = useNuxtApp()
// Подписываемся на все изменения всех stores
$pinia.use(({ store }) => {
store.$subscribe((mutation) => {
// Отправляем события в аналитику
if (mutation.type === 'direct') {
analytics.track('store-mutation', {
store: mutation.storeId,
key: mutation.events.key,
newValue: mutation.events.newValue,
})
}
})
})
})

// Как данные путешествуют с сервера на клиент:
// 1. На сервере:
const counter = useState('counter', () => 42)
// → сериализуется в HTML: window.__NUXT__ = { state: { counter: 42 } }
// 2. В HTML документе:
// <script>window.__NUXT__ = { state: { counter: 42 } }</script>
// 3. На клиенте при гидрации:
const counter = useState('counter', () => 42)
// → берёт 42 из window.__NUXT__.state.counter
// → initializer () => 42 НЕ вызывается!