Как не надо писать Store в Pinia (Vue). Разбираем на выдуманном примере
- понедельник, 27 апреля 2026 г. в 00:00:05
Сегодня посмотрим на вымышленный пример, как не надо делать стор. Любые совпадения - случайность. Все истории выдуманы.
Представьте: есть у нас герой Алекс. Перекидывают его на проект - «поправить пару простых багов, делов на пять минут». Открывает Алекс код, а там… У него сердце замирает. Подумаешь, с кем не бывает. Но внутри начинается дилема: просто пофиксить баги и забыть этот ужас как страшный сон, либо как настоящий богатырь проектов взять и отрефакторить весь этот бардак. Сделать по-человечески, заложить нормальную основу. Да, потом спросят за новые баги - ну и что. Зато внутри тепло разольётся, что не забил на плохой код и навёл порядок.

Ладно, хватит слов. Давайте к практике. Вот такой стор увидел Алекс (специально упростил, но суть та же):
export const usePropertyStore = defineStore('property', () => { const items = ref<Record<string, any>>({}) const isFetching = ref(false) watch( items, (val) => { localStorage.setItem('items', JSON.stringify(val)) }, { deep: true } ) const activeType = computed(() => localStorage.getItem('type')) const currentItem = computed(() => { if (!activeType.value) return null return items.value[activeType.value] }) async function load() { isFetching.value = true const result = await fetchProperties() isFetching.value = false return result } return { items, isFetching, activeType, currentItem, load } })
Алекса аж передёрнуло. Страшно было не то, что непонятно, а что этот код уже живой, его тестировали, им пользовались. Тронешь - и сам будешь отвечать за всё. Но Алекс не из робких. Собрался духом и начал разгребать.
Первое, что испугало - работа с localStorage прямо в сторе.

Сама по себе идея кешировать данные не криминал, но здесь ни одного хелпера, ни обработки ошибок. Если вдруг в localStorage что-то битое, JSON.parse выплюнет исключение и приложение упадёт в самый неподходящий момент. Я не говорю вообще не использовать localStorage, но в большинстве проектов он не нужен. А если и нужен - возьмите готовое решение от Pinia (плагин persistedstate), не изобретайте велосипедов. Если сами пишете обёртку - будьте добры, обрабатывайте парсинг и запись аккуратно, тестируйте. Здесь же просто запись без всякой защиты.
Что бы Алекс хотел увидеть:
export const saveToCache = (key, data) => { try { localStorage.setItem(key, JSON.stringify(data)) } catch (e) { // ... } }
Watch в сторе - почти всегда плохая практика.

Подняв историю коммитов и обсуждения в задачах, Алекс понял: это была незавершённая задумка - сохранять items в localStorage, чтобы после перезагрузки страницы сразу показывать данные, а не делать запрос заново. Логика понятна, но зачем городить watch с deep, который будет дёргаться на каждое изменение вложенных полей? Если бэк отдаёт быстро - лучше просто делать запрос по необходимости. Если уж очень надо кешировать - опять же, есть нормальные плагины. В нашем случае Алекс выяснил, что items можно было спокойно не хранить вообще, бэк давал данные за миллисекунды. Выпилили watch вместе с localStorage. Даже если необходимо сохранять, то есть понимание, где вызывается метод загрузки items, и мы можем сами отслеживать этот момент и вызывать localStorage.
Дальше полез в computed, который читает localStorage.getItem.
Честно, я сам офигел, когда Алекс показал. Вы когда-нибудь вешали чтение из localStorage прямо в computed? Мозг ломается, потому что это не работает.
activeType и currentItem выглядят реактивными, но это иллюзия. localStorage.getItem() - синхронная функция, которая не умеет сообщать Vue об изменениях. При первом рендере значение закешируется, а дальше, даже если пользователь обновит хранилище через консоль или другой компонент, computed не пересчитается.
Реактивность работает только с ref и reactive.
А как это использовалось? Нашлось быстро: в каком-то обработчике клика:
localStorage.setItem('type', type)
И сразу же роутер пушили на новую страницу. Там уже стор заново обращался к этому computed и подхватывал значение. Жесть. Никогда так не делайте. Если нужно передать параметр между страницами - используйте роутер по-человечески: пропсы через route params, query. Это чище и предсказуемо.
Переменная isFetching в сторе резанула глаз.
Спрашиваю Алекса: «Зачем она здесь?» Он плечами пожал. Оказалось, isFetching во всём приложении больше никто не читал. Только сам метод load его менял - и всё. Ребята, запомните: не надо писать переменные в сторе про запас. Если вам нужно отслеживать состояние загрузки - создайте локальный ref в компоненте, где вызываете метод. Это и чище, и понятнее. Уносим loading из стора и больше не паримся.
Это только первая часть рефакторинга. Самая минимальная база, от которой можно уже оттолкнуться и дальше править.
export const usePropertyStore = defineStore('property', () => { const items = ref<Record<string, any>>({}) const activeType = ref<string | null>(null) const currentItem = computed(() => activeType.value ? items.value[activeType.value] : null ) async function load() { return await fetchProperties() } return { items, activeType, currentItem, load } })

Рефакторинг - это не про «сделать красиво». Это про «сделать предсказуемо». Когда стор не делает лишних движений, не кеширует то, что не должен, и не пытается управлять роутером, его легче тестировать, расширять и передавать по наследству. А если вам вдруг достался вот такой «кошмар» - не паникуйте. Разберите по шагам, уберите лишнее, замените на стандартные решения. И да, всегда гоняйте тесты перед мерджем. Удачного кодинга :)