pinia-stores-that-do-not-hurt.md — vim

Pinia: когда стор нужен, а когда он лишний

Два комментария, которые я пишу на ревью чаще всего: «зачем здесь стор?» и «почему это не в сторе?». Разбираю границу и три классические грабли.


На ревью я чаще всего пишу два комментария: «зачем здесь стор?» и «почему это не в сторе?». Иногда в одном и том же PR. Граница действительно неочевидная, поэтому давай проговорим.

Когда стор нужен

Три признака. Совпало хотя бы два — заводи:

  • данные нужны несвязанным веткам дерева (шапка и модалка где-то в глубине страницы);
  • данные должны пережить размонтирование компонента (текущий пользователь, корзина, черновик формы);
  • данные меняются из нескольких мест (уведомления прилетают и от вебсокета, и по кнопке).

Не совпало ничего — это ref в компоненте. Открытая вкладка, состояние таблицы, значение инпута — не стор.

Сам стор

Pinia умеет два синтаксиса, я в командах оставил setup - он один в один как composable, и людям не надо держать в голове два стиля:

// stores/notifications.ts
export const useNotificationsStore = defineStore('notifications', () => {
  const items = ref<Notification[]>([]);
  const unread = computed(() => items.value.filter(n => !n.read).length);

  function push(n: Notification) {
    items.value.unshift(n);
  }

  function markRead(id: string) {
    const found = items.value.find(n => n.id === id);
    if (found) found.read = true;
  }

  return { items, unread, push, markRead };
});

ref - состояние, computed - геттеры, функции - экшены. Никакого this.

Грабля первая: деструктуризация

const store = useNotificationsStore();
const { unread } = store; // число застыло навсегда

Стор реактивный, а достав из него значение, ты получил снимок. Правильно так:

const store = useNotificationsStore();
const { items, unread } = storeToRefs(store); // реактивные
const { push, markRead } = store;             // функции достаём как есть

Функции деструктурировать можно - они не реактивные и контекст не теряют.

Грабля вторая: стор как кэш сервера

Половина сторов, которые я видел, выглядит одинаково:

const users = ref([]);
const loading = ref(false);
const error = ref(null);

async function fetchUsers() { /* тридцать строк try/catch/finally */ }

Это не состояние приложения. Это кэш ответа сервера, написанный руками. И дописывать его придётся самому: инвалидация, ретраи, дедупликация одинаковых запросов, фоновое обновление. Всё это уже есть в TanStack Query, поэтому я развожу так:

  • серверные данные - в query;
  • клиентское состояние - в Pinia (тема, права, корзина, открытые модалки).

Стор после такой чистки худеет раза в три. Подробнее про слой запросов - в заметке про API.

Грабля третья: use* на верхнем уровне модуля

// stores/auth.ts
const router = useRouter(); // вернёт undefined
export const useAuthStore = defineStore('auth', () => { ... });

Внутри функции стора - можно, снаружи - нет. В SSR это ещё и утечка состояния между запросами: один пользователь увидит данные другого. Ловится такое обычно в проде и на выходных.

Как понять, что стор нормальный

Он импортируется в тест и проверяется без монтирования компонента:

beforeEach(() => setActivePinia(createPinia()));

it('считает непрочитанные', () => {
  const store = useNotificationsStore();
  store.push({ id: '1', read: false, text: 'привет' });
  expect(store.unread).toBe(1);
});

Если для теста надо поднять пол-приложения - в сторе лишнее. Ещё пара мелочей: имя файла = имя стора = домен (auth, cart), никаких main и common, и внутри нет DOM, window и роутера.

Ссылки