ДокументацияVuePinia — хранилище состояния
Средний 12 мин чтения

Pinia — хранилище состояния

Pinia — официальное хранилище состояния для Vue 3. Создание stores, state, getters, actions, плагины и лучшие практики.

Vue 3Piniastate managementstoreVuex

Что такое Pinia

Pinia — официальное хранилище состояния для Vue 3, пришедшее на смену Vuex. Оно проще, лучше типизируется и работает с Composition API.

Установка:

npm install pinia

Подключение:

import { createPinia } from 'pinia'

app.use(createPinia())

Определение Store

Pinia поддерживает два синтаксиса: Options API (похож на Vuex) и Setup Store (как Composition API).

Options Store

// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Счётчик',
  }),

  getters: {
    doubled: (state) => state.count * 2,

    fullName(): string {
      return `${this.name}: ${this.count}`
    },
  },

  actions: {
    increment() {
      this.count++
    },

    incrementBy(amount: number) {
      this.count += amount
    },

    async fetchCount() {
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    },
  },
})

Setup Store

// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Счётчик')

  const doubled = computed(() => count.value * 2)
  const fullName = computed(() => `${name.value}: ${count.value}`)

  function increment() {
    count.value++
  }

  function incrementBy(amount: number) {
    count.value += amount
  }

  async function fetchCount() {
    const res = await fetch('/api/count')
    const data = await res.json()
    count.value = data.count
  }

  return { count, name, doubled, fullName, increment, incrementBy, fetchCount }
})

Setup Store гибче: можно использовать любые composables, watch(), watchEffect() и другие Composition API функции.

Использование Store

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <p>{{ counter.name }}: {{ counter.count }}</p>
  <p>Удвоенное: {{ counter.doubled }}</p>
  <button @click="counter.increment()">+</button>
  <button @click="counter.incrementBy(5)">+5</button>
</template>

Деструктуризация с storeToRefs

Прямая деструктуризация теряет реактивность:

const { count, name } = useCounterStore() // НЕ реактивно!

Используйте storeToRefs:

import { storeToRefs } from 'pinia'

const store = useCounterStore()
const { count, name, doubled } = storeToRefs(store) // реактивно

// Actions деструктуризировать можно напрямую
const { increment, incrementBy } = store

Изменение состояния

Через actions (рекомендуется)

counter.increment()
counter.incrementBy(10)

Напрямую

counter.count = 42
counter.$patch({ count: 42, name: 'Новое имя' })

$patch с функцией

counter.$patch((state) => {
  state.count = 0
  state.name = 'Сброс'
})

Сброс к начальным значениям

counter.$reset() // Работает только в Options Store

Для Setup Store — добавьте action вручную:

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)

  function $reset() {
    count.value = 0
  }

  return { count, $reset }
})

Подписка на изменения

$subscribe

Отслеживает все изменения state:

const store = useCounterStore()

store.$subscribe((mutation, state) => {
  console.log(mutation.type)   // 'direct' | 'patch object' | 'patch function'
  console.log(mutation.storeId) // 'counter'

  localStorage.setItem('counter', JSON.stringify(state))
})

$onAction

Отслеживает вызовы actions:

const unsubscribe = store.$onAction(({ name, after, onError }) => {
  console.log(`Action "${name}" вызвана`)

  after((result) => {
    console.log(`Action "${name}" завершена с результатом:`, result)
  })

  onError((error) => {
    console.error(`Action "${name}" ошибка:`, error)
  })
})

Плагины

Плагины расширяют функциональность всех stores:

Плагин для localStorage

// plugins/pinia-plugin.ts
import type { PiniaPluginContext } from 'pinia'

export function piniaLocalStoragePlugin({ store }: PiniaPluginContext) {
  const saved = localStorage.getItem(store.$id)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  store.$subscribe((_, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

Подключение:

const pinia = createPinia()
pinia.use(piniaLocalStoragePlugin)
app.use(pinia)

Плагин для добавления свойств

function timestampPlugin({ store }: PiniaPluginContext) {
  store.$state._createdAt = new Date().toISOString()
}

Практические примеры

Auth Store

// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  id: string
  email: string
  name: string
  role: 'admin' | 'user'
}

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(localStorage.getItem('token'))

  const isLoggedIn = computed(() => !!token.value)
  const isAdmin = computed(() => user.value?.role === 'admin')

  async function login(email: string, password: string) {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    })

    if (!res.ok) throw new Error('Неверные данные')

    const data = await res.json()
    token.value = data.token
    user.value = data.user
    localStorage.setItem('token', data.token)
  }

  async function fetchUser() {
    if (!token.value) return

    const res = await fetch('/api/auth/me', {
      headers: { Authorization: `Bearer ${token.value}` },
    })

    if (res.ok) {
      user.value = await res.json()
    } else {
      logout()
    }
  }

  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }

  return { user, token, isLoggedIn, isAdmin, login, fetchUser, logout }
})

Cart Store

// stores/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  const totalCount = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0),
  )

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0),
  )

  function addItem(product: Omit<CartItem, 'quantity'>) {
    const existing = items.value.find(item => item.id === product.id)

    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1 })
    }
  }

  function removeItem(productId: string) {
    items.value = items.value.filter(item => item.id !== productId)
  }

  function updateQuantity(productId: string, quantity: number) {
    const item = items.value.find(i => i.id === productId)
    if (item) {
      item.quantity = Math.max(0, quantity)
      if (item.quantity === 0) removeItem(productId)
    }
  }

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

  return { items, totalCount, totalPrice, addItem, removeItem, updateQuantity, clear }
})

Взаимодействие stores

Store может использовать другой store:

export const useCartStore = defineStore('cart', () => {
  const auth = useAuthStore()

  async function checkout() {
    if (!auth.isLoggedIn) throw new Error('Нужна авторизация')

    const res = await fetch('/api/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${auth.token}`,
      },
      body: JSON.stringify({ items: items.value }),
    })

    return res.json()
  }
})

Итог

Pinia — простое и мощное хранилище состояния. Setup Store даёт полную свободу Composition API. Деструктуризируйте через storeToRefs, подписывайтесь через $subscribe и $onAction, расширяйте через плагины. Pinia пришла на смену Vuex и стала стандартом для Vue 3.