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 и роутера.