Pinia вглубь: stores, getters, actions, плагины

Pinia — официальная библиотека управления состоянием для Vue: разбираем её устройство до плагинов.

Pinia — хранилище глобального состояния для Vue 3, построенное на Composition API: state, getters и actions без шаблонного кода Vuex.

Composable отлично переиспользует логику, но каждый его вызов создаёт новый экземпляр состояния. Когда нужно одно общее состояние на всё приложение — корзина, текущий пользователь, тема — нужен store. Pinia даёт его с типобезопасностью, поддержкой devtools и горячей перезагрузкой.

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

Store объявляют функцией defineStore с уникальным id. Есть два стиля: options (похож на Options API) и setup (как <script setup>). Начнём с options — он нагляднее.

<script>
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
  }),
  getters: {
    count: (state) => state.items.length,
    total: (state) => state.items.reduce((s, i) => s + i.price, 0),
  },
  actions: {
    add(product) {
      this.items.push(product)
    },
    clear() {
      this.items = []
    },
  },
})
</script>

State, getters, actions

State — это функция, возвращающая начальные данные (функция, чтобы у каждого приложения был свежий объект). Getters — вычисляемые значения, по сути computed поверх state; они кешируются и пересчитываются только при изменении зависимостей. Actions — методы, меняющие state; внутри них this указывает на сам store. Actions могут быть асинхронными — это естественное место для запросов к API.

Геттеры умеют опираться друг на друга и принимать аргументы через возврат функции — например, itemById вернёт (id) => state.items.find(i => i.id === id). Такой «фабричный» геттер уже не кешируется (он параметризован), и это нормально. А вот обычный total кешируется: пока корзина не изменилась, повторное чтение cart.total не пересчитывает сумму заново — ровно как computed в компоненте.

Setup-стиль

Тот же store в setup-стиле выглядит как composable: ref становится state, computed — getter, функция — action. Этот стиль гибче (можно использовать другие composables внутри) и многим ближе.

<script>
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])
  const count = computed(() => items.value.length)
  function add(product) { items.value.push(product) }
  return { items, count, add }
})
</script>

Использование в компоненте

В компоненте просто зовём хук store. Важная тонкость: чтобы достать реактивные поля через деструктуризацию, оборачивайте store в storeToRefs — иначе реактивность потеряется (та же история, что с reactive). Actions при этом можно деструктурировать напрямую, они не теряют связь.

<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from './stores/cart'

const cart = useCartStore()
const { items, count } = storeToRefs(cart) // реактивные state/getters
const { add, clear } = cart                // actions — как есть
</script>

<template>
  <p>Товаров: {{ count }}</p>
  <button @click="clear">Очистить</button>
</template>

Использование вне компонентов

Store можно дёргать и из обычного JS-модуля — например, в роутере или в утилите. Единственное условие: Pinia уже должна быть установлена в приложение (app.use(pinia)) к моменту вызова. Внутри гварда роутера это всегда так, поэтому вызов безопасен:

// упрощённая модель «глобального синглтона», как у store вне компонента
function createStore() {
  const state = { user: null }
  return {
    login: (name) => { state.user = name },
    get user() { return state.user },
  }
}
const auth = createStore()

function routeGuard(to) {
  if (to === '/admin' && !auth.user) return '/login'
  return to
}

console.log(routeGuard('/admin'))
auth.login('Аня')
console.log(routeGuard('/admin'))

Вывод:

/login
/admin

Плагины Pinia

Плагин — функция, которую Pinia вызывает для каждого store. Через неё можно добавить общее свойство всем store или подписаться на изменения. Классический пример — автосохранение state в localStorage:

// плагин: сохраняем state каждого store при изменении
function persistPlugin({ store }) {
  const saved = localStorage.getItem(store.$id)
  if (saved) store.$patch(JSON.parse(saved))

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

// подключение: pinia.use(persistPlugin)
console.log('Плагин вызовется для каждого store по его $id')

Вывод:

Плагин вызовется для каждого store по его $id

Здесь видны служебные API store: $id — его идентификатор, $patch — частичное обновление state, $subscribe — подписка на любые мутации. На них и держатся плагины.

Pinia против Vuex

АспектVuexPinia
Мутацииотдельная сущность mutationsнет — меняем state прямо в actions
Модуливложенные namespaced-модулимного плоских store
Типизация TSгромоздкаявыводится автоматически
APIбольше шаблонного кодаминимализм, как Composition API

Ключевое отличие: в Vuex для изменения state нужно было идти через mutations (а асинхронность — через actions, вызывающие mutations). Pinia убрала этот слой: state меняется напрямую в actions. Pinia — официально рекомендованный преемник Vuex.

Как это работает под капотом

Внутри Pinia state каждого store — это reactive-объект, getters — computed, а сам store кешируется как синглтон по своему id. Первый вызов useCartStore() создаёт и регистрирует store в активном экземпляре Pinia; последующие вызовы возвращают тот же объект — отсюда «глобальность». storeToRefs проходит по полям store и оборачивает state/getters в ref, оставляя методы нетронутыми. Плагины — это просто массив функций, который Pinia прогоняет при создании каждого store.

Частые ошибки

  • Деструктурировать store напрямую (const { items } = cart) — теряется реактивность; нужен storeToRefs.
  • Прогонять storeToRefs по actions — лишнее; методы берут прямо со store.
  • Делать state объектом, а не функцией — все приложения станут делить один объект (опасно в SSR).
  • Вызывать store до app.use(pinia) — ошибка «no active Pinia».
  • Искать mutations как в Vuex — их нет, меняйте state прямо в action.

Итоги

  • Store определяют через defineStore в options- или setup-стиле; id уникален.
  • State — функция с данными, getters — кешируемые computed, actions — методы (могут быть async).
  • Деструктуризацию state/getters делайте через storeToRefs, actions берите напрямую.
  • Store работает и вне компонентов, если Pinia уже установлена в приложение.
  • Плагины расширяют все store через $id, $patch, $subscribe; Pinia — преемник Vuex без mutations.
Проверьте себя
1. Как корректно деструктурировать реактивные state и getters из Pinia store?
Aconst { items } = useCartStore() напрямую
BЧерез storeToRefs(store), чтобы сохранить реактивность
CЧерез JSON.parse(JSON.stringify(store))
DРеактивные поля деструктурировать нельзя вообще
2. Чем Pinia принципиально отличается от Vuex?
AВ Pinia нет отдельных mutations — state меняется прямо в actions
BPinia не поддерживает getters
CPinia работает только с Options API
DВ Pinia нельзя хранить массивы
3. Что такое плагин Pinia?
AОтдельный npm-пакет для роутинга
BФункция, которую Pinia вызывает для каждого store и которая может добавить свойства или подписку
CСпособ объявить новый компонент
DЗамена storeToRefs