Pinia state management - то, чем я закрываю хранение данных в каждом дашборде на Vue 3 последние года два: с тех пор как Vuex перестал развиваться и превратился в поддерживаемый, но замороженный проект, Pinia стала дефолтным выбором для таблиц, фильтров, графиков и пользовательских сессий. На практике я собирал BI-панели для интернет-магазинов на WooCommerce, где нужно было в реальном времени сводить статусы доставки СДЭК, платежи через эквайринг Т‑Банка и остатки на складе - и без продуманной архитектуры stores такой проект за пару месяцев превращается в клубок дублирующихся fetch-запросов и рассинхронизированных данных между виджетами. Ниже - как я организую stores, actions, getters, персистентность и тесты, и какие грабли по дороге ловил.
Чем Pinia отличается от Vuex и почему это стандарт управления состоянием в Vue 3
Vuex формально жив, но с релиза Vue 3 команда сама рекомендует новые проекты собирать на Pinia - она вошла в официальную документацию и получила поддержку в Vue Devtools ещё до того, как Vuex 5 вообще анонсировали. На практике разница ощущается в трёх местах.
Мутации исчезли - в Pinia state меняется прямо в actions, без отдельного слоя mutations, который в Vuex существовал только ради devtools-трекинга. Меньше кода, меньше файлов на каждый store.
Типизация в TypeScript выводится автоматически из state, getters и actions - не нужно вручную прописывать типы для commit и dispatch, как в Vuex. Для дашборда с десятком stores это экономит часы на рефакторинге, когда меняется форма ответа API.
Stores плоские, без namespaced-модулей с вложенностью через слэши в имени action. Каждый store - независимый файл, вызывается как обычная composable-функция.
| Критерий | Vuex 4 | Pinia |
|---|---|---|
| Мутации | Отдельный слой commit | Нет, меняем state прямо в actions |
| Типизация TS | Ручная, много boilerplate | Автовывод типов |
| Модули | Namespaced, вложенные | Плоские независимые stores |
| Размер рантайма | ~9 кб | ~1,5 кб |
| Devtools | Есть | Есть, плюс time-travel по каждому store отдельно |
Для новых проектов я беру Pinia без раздумий, а на старых, где ещё жив Vuex 3-4, мигрирую точечно - store за store, а не всё разом, потому что оба хранилища спокойно работают в одном приложении на время переходного периода.
Структура stores: как я делю состояние дашборда по доменам
Главная ошибка, которую я видел в чужих проектах и сам допускал в первых двух дашбордах - один store на всё приложение. useDashboardStore на 400 строк с заказами, пользователем, фильтрами и настройками темы. Он быстро становится узким местом: любое изменение задевает половину компонентов, а в devtools невозможно понять, что вообще произошло.
Я делю stores по доменам данных, а не по экранам:
- useAuthStore - токен, профиль, права доступа
- useOrdersStore - список заказов, пагинация, статусы оплаты
- useDeliveryStore - статусы СДЭК, трек-номера, стоимость доставки
- useAnalyticsStore - агрегаты для графиков: выручка по дням, конверсия, средний чек
- useFiltersStore - период, склад, менеджер - общие фильтры, которые читают несколько виджетов
Store для заказов в Composition API выглядит так:
// stores/orders.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { fetchOrders } from '@/api/orders'
export const useOrdersStore = defineStore('orders', () => {
const items = ref([])
const isLoading = ref(false)
const error = ref(null)
const totalRevenue = computed(() =>
items.value.reduce((sum, order) => sum + order.amount, 0)
)
const paidOrders = computed(() =>
items.value.filter((order) => order.status === 'paid')
)
async function load(params) {
isLoading.value = true
error.value = null
try {
items.value = await fetchOrders(params)
} catch (e) {
error.value = e.message
} finally {
isLoading.value = false
}
}
return { items, isLoading, error, totalRevenue, paidOrders, load }
})
Getters здесь - обычные computed, они пересчитываются лениво и кешируются, пока не поменяется зависимость. В Options API синтаксисе то же самое пишется через getters: {} и actions: {}, я использую его в проектах, где команда ещё не привыкла к Composition API - разницы в производительности нет, это чисто вопрос синтаксиса.
Стартовые заготовки stores с готовой типизацией и структурой под дашборд я собрал в библиотеке готовых скриптов - беру их как основу вместо того, чтобы каждый раз писать boilerplate заново.
Actions и getters: где живёт бизнес-логика дашборда
Правило, которое я соблюдаю жёстко: компонент не должен знать, как считается выручка или какой у заказа статус после трёх разных проверок - вся эта логика уходит в getters и actions store, а компонент только читает готовые значения через storeToRefs.
Частая ошибка - деструктурировать store напрямую:
const { items, totalRevenue } = useOrdersStore() // реактивность потеряна
Так реактивность рвётся, потому что деструктуризация вытаскивает примитивные значения, а не ссылки. Правильно - через storeToRefs:
import { storeToRefs } from 'pinia'
const store = useOrdersStore()
const { items, totalRevenue } = storeToRefs(store)
const { load } = store // actions деструктурировать можно, они не теряют this
Actions я оставляю асинхронными по умолчанию, даже если сейчас логика синхронная - на дашборде рано или поздно она обрастёт запросом к API, и не придётся переписывать сигнатуру во всех местах вызова.
Тестирование actions через Vitest
Stores тестирую отдельно от компонентов - это быстрее и ловит регрессии в бизнес-логике до того, как она долетит до UI:
import { setActivePinia, createPinia } from 'pinia'
import { useOrdersStore } from '@/stores/orders'
import { describe, it, expect, beforeEach } from 'vitest'
describe('orders store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('считает выручку по оплаченным заказам', () => {
const store = useOrdersStore()
store.items = [
{ amount: 1000, status: 'paid' },
{ amount: 500, status: 'pending' },
]
expect(store.totalRevenue).toBe(1500)
})
})
На дашборде с денежными расчётами (выручка, комиссии эквайринга, скидки) такие тесты один раз ловили ошибку округления, которая на проде обернулась бы неверными цифрами в отчёте для клиента.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Асинхронная загрузка данных: заказы, СДЭК и оплаты из Т‑Банка
Дашборд редко работает с одним источником данных. В одном из проектов на WooCommerce я собирал панель, где в одном экране сходились: заказы из WooCommerce REST API, статусы доставки СДЭК по трек-номеру, платежи из личного кабинета эквайринга Т‑Банка и агрегаты, которые предварительно считал сценарий в n8n и складывал в отдельную таблицу - тянуть три API на клиенте ради одного графика было бы медленно и хрупко.
Внутри store каждый источник - отдельный action с собственным флагом загрузки и обработкой ошибок:
let controller
async function loadDeliveryStatus(orderId) {
controller?.abort()
controller = new AbortController()
isLoading.value = true
try {
const res = await fetch(`/api/cdek/status/${orderId}`, {
signal: controller.signal,
})
status.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') error.value = e.message
} finally {
isLoading.value = false
}
}
AbortController здесь не декоративный - если менеджер быстро переключает заказы в списке, предыдущий запрос статуса доставки долетает позже нового и без отмены перезаписывает актуальные данные устаревшими. На дашборде с частым переключением фильтров это реальный баг, который без AbortController воспроизводится за пару минут ручного тестирования.
Для данных, которые обновляются на бэкенде асинхронно - например, статус вебхука от n8n о новом заказе - я не гоняю polling каждые несколько секунд, а держу WebSocket-соединение и обновляю state store через один action onOrderUpdate, который дёргается из подписки. Это разгружает и бэкенд, и сам стор - не нужно сравнивать старый и новый список заказов вручную.
Персистентность и синхронизация состояния между вкладками
Фильтры дашборда (период, склад, менеджер) я почти всегда сохраняю между перезагрузками - иначе пользователь каждое утро заново выставляет диапазон дат. Делаю это через pinia-plugin-persistedstate, а не руками через watch и localStorage.setItem, потому что плагин сам разруливает сериализацию и подписку на изменения:
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export const useFiltersStore = defineStore('filters', {
state: () => ({
period: 'month',
warehouse: null,
}),
persist: {
storage: sessionStorage,
pick: ['period', 'warehouse'],
},
})
Токен авторизации в localStorage не храню - только в httpOnly cookie или в памяти store с обновлением через refresh-токен: localStorage читается любым скриптом на странице, и это лишняя поверхность для XSS.
Для синхронизации между вкладками (открыл дашборд в двух окнах - выставил фильтр в одном, он должен обновиться и в другом) localStorage плюс событие storage работает, но с задержкой и не во всех сценариях. Я использую BroadcastChannel - он создан ровно для этой задачи и не требует костылей с window.addEventListener(‘storage’):
const channel = new BroadcastChannel('dashboard-filters')
channel.onmessage = (event) => {
filtersStore.$patch(event.data)
}
filtersStore.$subscribe((_, state) => {
channel.postMessage(state)
})
$subscribe здесь - встроенный механизм Pinia для подписки на изменения store без Vuex-овских плагинов, отписка происходит автоматически при размонтировании компонента, если передать { detached: false }.
Ошибки при внедрении Pinia, с которыми я сталкивался на практике
Собрал сюда то, что реально стоило времени на дебаг, а не теорию из документации.
Один гигантский store вместо доменного деления - уже разобрал выше, но добавлю: рефакторинг такого store на боевом проекте с 20+ компонентами занимает у меня от 2-3 дней, и лучше не доводить до этой точки.
Мутация state в обход actions прямо из компонента (store.items.push(...) вместо store.addItem(...)) - работает, Pinia это не запрещает, но логика размазывается по компонентам и её невозможно переиспользовать или протестировать отдельно.
Забытая отписка от $subscribe в компонентах, которые монтируются и размонтируются часто (модалки, вкладки) - за несколько часов работы дашборда накапливается десяток мёртвых подписчиков, и профилировщик начинает показывать рост памяти.
Хранение производных данных в state вместо getters - например, отдельное поле filteredItems, которое обновляют вручную при каждом изменении фильтра. Рано или поздно кто-то забывает его пересчитать после очередного изменения, и виджет показывает не то. Getters решают это самим фактом ленивого пересчёта.
По деньгам: доработка Pinia-архитектуры на существующем дашборде, где уже накопился техдолг из первого пункта, у меня стоит от 90 000 ₽ - сюда входит разбивка на stores по доменам, типизация и покрытие ключевой бизнес-логики тестами. У студий на рынке цена такой работы обычно стартует от 150 000 ₽ и растёт в зависимости от объёма компонентов, которые приходится переписывать под новую архитектуру.
Интерфейс аналитики и отчётов
Дашборд / BI
от 90 000 ₽
Подробнее →Частые вопросы
Можно ли использовать Pinia без TypeScript?
Да, Pinia прекрасно работает на чистом JavaScript и в Options API синтаксисе - типизация просто дополнительный бонус, а не обязательное условие. На небольших дашбордах я иногда сознательно остаюсь на JS, если проект не будет расти дальше нескольких десятков компонентов.
Чем Pinia лучше Vuex для нового проекта на Vue 3?
Меньше boilerplate за счёт отсутствия мутаций, автоматический вывод типов, официальная поддержка команды Vue и работа как в Composition, так и в Options API. Для нового проекта на Vue 3 я не вижу причин брать Vuex.
Нужно ли использовать Pinia в маленьком дашборде на 3-4 компонента?
Если данные не расшариваются между компонентами и достаточно props и provide/inject, отдельный store - лишний слой. Я подключаю Pinia, как только вижу, что два независимых компонента должны видеть одни и те же данные, например фильтр периода влияет и на таблицу, и на график.
Как перенести проект с Vuex на Pinia без переписывания всего приложения?
Оба хранилища работают одновременно в одном приложении - я мигрирую store за store, начиная с тех модулей, где чаще всего меняется бизнес-логика, а старые Vuex-модули оставляю нетронутыми до своей очереди. Полная миграция дашборда среднего размера у меня обычно занимает 1-2 недели без остановки разработки новых фич.