Pinia — управление состоянием во Vue

Pinia — официальный стейт-менеджер Vue 3. Когда несколько компонентов должны читать и менять одни и те же данные (корзина, пользователь, настройки), их выносят в store — централизованное хранилище с реактивным state, getters и actions.

Уровень: средний 17 разделов ⏱ 1.5–2 часа

1. Зачем нужен store

Данные между братьями (компонентами) двигаются «вверх и вниз» через props/emits. Это ломается, когда цепочка растёт. Store решает проблему:

Pinia — официальная замена Vuex. API чище (нет мутаций, как в Vuex), автоподдержка TypeScript и простое использование. В новых проектах ставят именно Pinia.

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>
Не деструктурируй store напрямую: const { count } = store потеряет реактивность. Если нужно — через storeToRefs.

5. StoreToRefs — реактивная деструктуризация

import { storeToRefs } from "pinia";

const store = useCounterStore();
const { count, double } = storeToRefs(store);  // ref-ы, реактивны
const { increment } = store;                   // action можно просто так
storeToRefs «разворачивает» state и getters в отдельные ref-переменные — удобно в шаблоне без слова 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;
    }
  }
});
Возвращаемый тип getters и actions выводится автоматически. Явные аннотации стоит ставить у state, чтобы тип хранилища был строгим.

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
});
Храни в localStorage только то, что можно восстановить: данные корзины, тему, токен. Чувствительные данные и большие объёмы туда не помещай.

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 — обычные функции.

Setup store удобен при сложной логике: куски состояния можно выносить в хелперы и переиспользовать между store, не дублируя опции.

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 — тесты должны быть быстрыми и детерминированными.

С persist-плагином меняй localStorage на in-memory реализацию, иначе состояние «прилипнет» между тестами и даст ложно-зелёный результат.

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));
});
Не делай в подписке тяжёлую работу: она выполняется синхронно при каждой мутации. Сложные операции выноси в actions и вызывай явно.

15. Модульная структура сторов

Один store на домен: пользователь, корзина, настройки, каталог. «Божественный» store со всем сразу превращается в несинхронизированную мешанину:

src/stores/
├── auth.ts          // сессия, токен, права
├── cart.ts          // состав корзины, суммы
├── catalog.ts       // товары, фильтры, пагинация
└── ui.ts            // модалки, тосты, тема
Если two stores постоянно читают друг друга — выносите общий фрагмент в третий store. Прямые cross-imports усложняют тесты и циклы зависимостей.

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);                         // откат
    }
  }
}
Оптимистичное обновление всегда предусматривай на «откат»: без восстановления состояния UI останется обманутым, если запрос упадёт.

17. Чек-лист и частые ошибки

ОшибкаКак избежать
Вызов useStore() на верхнем уровне модулявызывать внутри функций setup/actions
Мутация state напрямую «через» компонент без actionвыносить изменения в actions
Деструктуризация без storeToRefsоборачивать state/getters в storeToRefs
Хранение computed-данных в stateиспользовать getters
Persist всего store целикомсохранять только нужные поля
Регулярно пересматривай «что из state реально читается»: мёртвые поля замедляют сериализацию persist и путают при работе в команде.

18. Отладка через Vue DevTools

Vue DevTools — обязательный союзник Pinia. Вкладка «Pinia» показывает содержимое каждого store в реальном времени: изменение state видно мгновенно, а timeline хранит историю вызовов actions. Если вместо данных пусто — проверь, что экземпляр pinia установлен до монтирования приложения:

import { createPinia } from "pinia";
app.use(createPinia());   // иначе DevTools не обнаружит store
После правки store обнови DevTools: состояние глобального инстанса может остаться от предыдущей загрузки страницы.
Логирование в консоль оставляй только в DEV-режиме (import.meta.env.DEV или process.env.NODE_ENV) — продакшен-консоль должна быть чистой.

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(); }
  }
});
Не храни в store состояние самой формы (фокус, открытый дропдаун): это сугубо компонентная реактивность и не должно триггерить глобальные подписки.

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([])           // массив заменяется целиком
  })
});
Профилируй через Vue DevTools → Performance: лишние обновления видны сразу. Оптимизируй только там, где есть реальная задержка.

21. Миграция с Vuex

Переход с Vuex на Pinia механический, но с ловушками. Ключевые соответствия:

VuexPinia
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);
Хороший промежуточный шаг — на время переезда держать оба пакета и мигрировать страницами, не трогая остальные модули приложения.

Что дальше

Собери мини-приложение на Vue 3: список товаров, корзина в Pinia, форма с Element Plus. API приходит с бэкенда — паттерны запросов в курсе REST API, а тесты стора пишутся на Vitest.

← Вернуться к курсам