Pinia — управление состоянием во Vue
Pinia — официальный стейт-менеджер Vue 3. Когда несколько компонентов должны читать и менять одни и те же данные (корзина, пользователь, настройки), их выносят в store — централизованное хранилище с реактивным state, getters и actions.
1. Зачем нужен store
Данные между братьями (компонентами) двигаются «вверх и вниз» через props/emits. Это ломается, когда цепочка растёт. Store решает проблему:
- один источник истины для всех компонентов;
- нет бесконечного прокидывания props;
- логика запросов и трансформации живёт в одном месте;
- с DevTools Vue видно каждый вызов action.
2. Установка
npm install pinia// main.ts
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
const pinia = createPinia();
createApp(App).use(pinia).mount("#app");3. Создание store: defineStore
// stores/counter.ts
import { defineStore } from "pinia";
export const useCounterStore = defineStore("counter", {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2
},
actions: {
increment() {
this.count++;
}
}
});Имена store начинаются с use и заканчиваются на Store: useCartStore.
4. Использование в компонентах
<script setup lang="ts">
import { useCounterStore } from "../stores/counter";
const store = useCounterStore();
// store.count, store.double, store.increment()
</script>
<template>
<p>Счёт: {{ store.count }}</p>
<p>Удвоенный: {{ store.double }}</p>
<button @click="store.increment">+1</button>
</template>const { count } = store потеряет реактивность. Если нужно — через storeToRefs.5. StoreToRefs — реактивная деструктуризация
import { storeToRefs } from "pinia";
const store = useCounterStore();
const { count, double } = storeToRefs(store); // ref-ы, реактивны
const { increment } = store; // action можно просто такstore..6. Getters и Actions
Getters — вычисляемые значения на основе state. Actions — методы с бизнес-логикой:
// stores/cart.ts
import { defineStore } from "pinia";
interface CartItem { id: number; title: string; price: number; qty: number }
export const useCartStore = defineStore("cart", {
state: () => ({ items: [] as CartItem[] }),
getters: {
total(state): number {
return state.items.reduce((sum, i) => sum + i.price * i.qty, 0);
},
count(state): number {
return state.items.reduce((sum, i) => sum + i.qty, 0);
}
},
actions: {
add(item: CartItem) {
const found = this.items.find(i => i.id === item.id);
if (found) found.qty += item.qty;
else this.items.push({ ...item });
},
remove(id: number) {
this.items = this.items.filter(i => i.id !== id);
}
}
});7. Async actions — запросы к API
Действия могут быть асинхронными — типичный паттерн загрузки данных:
// stores/users.ts
import { defineStore } from "pinia";
export const useUserStore = defineStore("users", {
state: () => ({
list: [] as User[],
loading: false,
error: null as string | null
}),
actions: {
async fetchUsers() {
this.loading = true;
this.error = null;
try {
const res = await fetch("/api/users");
this.list = await res.json();
} catch (e: any) {
this.error = e.message;
} finally {
this.loading = false;
}
}
}
});// в компоненте при монтировании
onMounted(() => store.fetchUsers());8. Pinia + TypeScript
С type-объявлениями IDE подсказывает поля и типы автоматически:
interface AuthState {
user: { id: number; name: string } | null;
token: string | null;
}
export const useAuthStore = defineStore("auth", {
state: (): AuthState => ({ user: null, token: null }),
getters: {
isAuth: (s) => !!s.token
},
actions: {
login(name: string) {
this.user = { id: 1, name };
this.token = "fake-jwt-token";
},
logout() {
this.user = null;
this.token = null;
}
}
});9. Плагины и персистентность
Плагины расширяют все store разом. Самый частый кейс — сохранение в localStorage:
npm install pinia-plugin-persistedstate// main.ts
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
pinia.use(piniaPluginPersistedstate);
// в store достаточно добавить настройку
export const useCartStore = defineStore("cart", {
state: () => ({ items: [] as CartItem[] }),
persist: true
});10. Setup-синтаксис: составные store
Кроме опций, Pinia поддерживает function-синтаксис: state, getters и actions становятся обычными переменными и функциями внутри замыкания. Это ближе к Composition API и удобно для переиспользуемой логики:
export const useCounterStore = defineStore("counter", () => {
const count = ref(0);
const double = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, double, increment };
});Снаружи API не меняется: store.count, store.double, store.increment() работают точно так же. Геттеры — это computed, actions — обычные функции.
11. Обращение к другим store
В actions одного store можно использовать другой: вызови useOtherStore() прямо внутри функции. Pinia разрешает зависимости автоматически, без проброса ссылок через параметры:
export const useCartStore = defineStore("cart", {
actions: {
checkout() {
const auth = useAuthStore(); // другой store
const orders = useOrdersStore();
if (!auth.isAuth) return;
orders.create(this.items);
this.items = [];
}
}
});В геттерах обращение к другому store работает так же. Главное — не создавай циклические зависимости: если один store читает другой, а тот — первый, разносите общую часть в третий store или в обычный модуль.
useStore() на верхнем уровне модуля: в этот момент Pinia ещё не активирована. Вызов внутри action или getter всегда безопасен.12. Использование вне компонентов
Store применяется в роутер-гардах, сервисах и помощниках. Нужно лишь передать активный экземпляр Pinia, созданный через createPinia():
// router.ts — за пределами компонентов
import { useAuthStore } from "@/stores/auth";
router.beforeEach(() => {
const auth = useAuthStore(pinia);
if (!auth.isAuth) return "/login";
});Если инстанс не передать, получишь ошибку «getActivePinia was called with no active Pinia».
В Nuxt 3 Pinia подключается модулем @pinia/nuxt, а контекст создаётся автоматически — store доступны и в composables, и в плагинах. При SSR состояние, зависящее от пользователя, не забывай сбрасывать перед гидрацией.
13. Тестирование store
Хранилища тестируются как обычный модуль: создай свежий Pinia, получи store и проверяй поведение. Пример на Vitest:
import { createPinia, setActivePinia } from "pinia";
beforeEach(() => {
setActivePinia(createPinia());
});
it("увеличивает счётчик", () => {
const store = useCounterStore();
store.increment();
expect(store.count).toBe(1);
});Сетевые вызовы внутри actions подменяй через vi.mock или fetch-mock — тесты должны быть быстрыми и детерминированными.
14. $subscribe и $onAction — наблюдаемость
Помимо реактивного чтения, Pinia позволяет следить за изменениями вручную. $subscribe ловит каждую мутацию state, $onAction — вызовы actions:
// сохранять корзину при каждом изменении, минуя компоненты
store.$subscribe((mutation, state) => {
localStorage.setItem("cart:items", JSON.stringify(state.items));
});
// логирование действий: имя, аргументы, результат
store.$onAction(({ name, args, after, onError }) => {
console.log(`action: ${name}`, args);
after((result) => console.log(`done: ${result}`));
onError((error) => console.error(error));
});- подписки полезны для аналитики, синхронизации и «грязных» флагов формы;
- возвращаемая функция отписки убирает слушатель — вызывай её при размонтировании;
$subscribeпо умолчанию реагирует только на изменения компонента-владельца, флагdetached: trueотвязывает подписку.
15. Модульная структура сторов
Один store на домен: пользователь, корзина, настройки, каталог. «Божественный» store со всем сразу превращается в несинхронизированную мешанину:
src/stores/
├── auth.ts // сессия, токен, права
├── cart.ts // состав корзины, суммы
├── catalog.ts // товары, фильтры, пагинация
└── ui.ts // модалки, тосты, тема- называй файл и store одним словом:
cart.ts→useCartStore; - переиспользуемую логику доставай в composable или setup-store;
- не храни в стейте то, что вычислимо: за «виртуальное» отвечают getters.
16. Кэширование и оптимистичные обновления
Частая задача — не дёргать API повторно. Храни флаг загрузки и время актуальности, а действия делай «оптимистичными»: меняй state сразу, откатывай при ошибке:
actions: {
async fetchCatalog(force = false) {
if (!force && this.items.length) return; // свежие данные уже есть
this.loading = true;
this.items = await api.get("/catalog");
this.loading = false;
},
async removeItem(id) {
const removed = this.items.find(i => i.id === id);
this.items = this.items.filter(i => i.id !== id); // оптимистично
try {
await api.deleteItem(id);
} catch {
this.items.push(removed); // откат
}
}
}- флаг
loadingи полеerrorдержи в state, а не в компоненте; - таймаут и повторные попытки — в async action, счётчик ретраев храни в store.
17. Чек-лист и частые ошибки
| Ошибка | Как избежать |
|---|---|
| Вызов useStore() на верхнем уровне модуля | вызывать внутри функций setup/actions |
| Мутация state напрямую «через» компонент без action | выносить изменения в actions |
| Деструктуризация без storeToRefs | оборачивать state/getters в storeToRefs |
| Хранение computed-данных в state | использовать getters |
| Persist всего store целиком | сохранять только нужные поля |
18. Отладка через Vue DevTools
Vue DevTools — обязательный союзник Pinia. Вкладка «Pinia» показывает содержимое каждого store в реальном времени: изменение state видно мгновенно, а timeline хранит историю вызовов actions. Если вместо данных пусто — проверь, что экземпляр pinia установлен до монтирования приложения:
import { createPinia } from "pinia";
app.use(createPinia()); // иначе DevTools не обнаружит store- инспекция: поля state, getters и actions сгруппированы и читаются при наведении;
- time-travel: откатись к состоянию до вызова action и сравни, что именно изменилось;
- вкладка Performance покажет, какой компонент отреагировал на изменение store.
19. Формы: черновик в store
Многошаговые формы — классический сценарий Pinia: черновик живёт в store, и данные не теряются при переходах между экранами. Паттерн «draft + commit + reset»:
export const useCheckoutStore = defineStore("checkout", {
state: () => ({
draft: { email: "", address: "" },
saving: false
}),
getters: {
isDirty: (s) => !!s.draft.email || !!s.draft.address
},
actions: {
setField(field, value) { this.draft[field] = value; },
async submit() {
this.saving = true;
try { await api.placeOrder(this.draft); }
finally { this.saving = false; }
},
reset() { this.$reset(); }
}
});- показывай dirty-флаг в шапке и предупреждай при уходе со страницы с незаполненными полями;
- валидацию держи вне store (схема Zod), в action передавай уже проверенные данные;
- автосохранение черновика в localStorage — через $subscribe с debounce, не на каждый keystroke.
20. Тонкая настройка реактивности
Каждое поле state становится reactive-прокси, каждый getter — computed. Если в store живут большие массивы или тяжёлые классы-сервисы, оберни их в shallowRef и markRaw, чтобы Pinia не «пронизывала» их реактивностью:
import { defineStore, shallowRef, markRaw } from "pinia";
export const useEditorStore = defineStore("editor", {
state: () => ({
doc: markRaw(new EditorApi()), // класс-хелпер вне реактивности
layers: shallowRef([]) // массив заменяется целиком
})
});- shallowRef выгоден, когда массив перезаписывается пачкой, а не мутируется по элементам;
- markRaw убирает прокси с объектов, которые не участвуют в рендере (карты, плееры);
- точечные мутации вложенных объектов всё равно ловит $subscribe — следи за глубиной структуры;
- если одно изменение триггерит десятки компонентов — проверь, не читают ли они тяжёлый getter.
21. Миграция с Vuex
Переход с Vuex на Pinia механический, но с ловушками. Ключевые соответствия:
| Vuex | Pinia |
|---|---|
| mutations | не нужны — меняй state прямо в actions |
| module с namespaced: true | отдельный файл-стор |
| this.$store.commit(...) | useCartStore().add(...) |
| getters из других модулей | вызов useOtherStore() внутри getter |
// было: this.$store.dispatch("cart/add", item)
// стало:
const cart = useCartStore();
cart.add(item);- мигрируй по одному store за раз и прогоняй тесты между шагами — не переписывай всё в одном PR;
- глобальные подписки Vuex-плагина заменяй на $subscribe или собственный плагин Pinia;
- у Pinia нет встроенного strict-режима — вынеси изменения в actions как договорённость команды.
Что дальше
Собери мини-приложение на Vue 3: список товаров, корзина в Pinia, форма с Element Plus. API приходит с бэкенда — паттерны запросов в курсе REST API, а тесты стора пишутся на Vitest.