Паттерны состояния: когда Pinia, когда composable, когда provide

Не всякое состояние требует Pinia. Учимся выбирать между локальным ref, composable, provide/inject и глобальным store.

Управление состоянием — это в первую очередь выбор области видимости: насколько широко состояние должно быть доступно и кто им владеет.

Частая ошибка новичка — тащить всё в Pinia «на всякий случай». Это раздувает глобальное пространство, усложняет тесты и размывает ответственность. У вас есть лестница инструментов от самого локального к самому глобальному — берите наименьший достаточный.

Почему это важно. Глобальное состояние — это, по сути, общая изменяемая переменная, видимая отовсюду. Чем его больше, тем труднее ответить на вопрос «кто и когда это поменял», тем легче поймать неожиданную связанность между несвязанными экранами и тем дороже писать изолированные тесты. Локальное же состояние самодокументируемо: оно живёт рядом с тем, кто им владеет. Поэтому правило простое — поднимайте состояние на уровень выше только тогда, когда этого реально требуют потребители.

Уровень 1: локальный ref

Если состояние нужно одному компоненту — открыт ли дропдаун, что в поле ввода — это просто ref в этом компоненте. Никаких composable и store. Глобализировать такое — антипаттерн: вы делаете приватную деталь видимой всему приложению.

<script setup>
import { ref } from 'vue'
const isOpen = ref(false) // живёт и умирает вместе с компонентом
</script>

Уровень 2: composable для переиспользуемой логики

Если одна и та же логика с состоянием нужна в нескольких компонентах, но каждому — свой экземпляр (свой таймер, своя позиция мыши, свой результат запроса), это composable. Помните: каждый вызов useX() создаёт независимое состояние.

function useToggle(initial = false) {
  let state = initial
  return {
    toggle: () => { state = !state; return state },
    get value() { return state },
  }
}

const a = useToggle()
const b = useToggle(true)
console.log(a.toggle())  // независим от b
console.log(b.value)
console.log(a.value)

Вывод:

true
true
true

Видно, что a и b не влияют друг на друга — у каждого своё состояние. Это ключевое свойство composable: переиспользование логики, а не данных.

Уровень 3: общее состояние без Pinia

Иногда нужно одно общее состояние, но тянуть Pinia ради него — перебор. Тогда работает приём «shared composable»: объявляем ref вне функции, в области модуля. Поскольку модуль импортируется один раз, все компоненты получают один и тот же ref.

<script>
import { ref } from 'vue'

// ref вне функции — общий на всё приложение
const count = ref(0)

export function useSharedCounter() {
  const inc = () => count.value++
  return { count, inc } // тот же count у всех импортёров
}
</script>

Это легально и удобно для небольшого глобального состояния. Минус — нет devtools, нет структурированных actions, сложнее тестировать в изоляции (общий ref переносит своё значение между тестами, если его не сбрасывать). Ещё нюанс — SSR: модульный ref на сервере общий для всех запросов сразу, что в серверном рендеринге опасно утечкой данных между пользователями. Поэтому для сервера такой приём не годится без аккуратности. Как только состояние растёт или появляется SSR — переходите на Pinia, которая всё это учитывает.

Уровень 4: provide / inject

provide/inject решает другую задачу — не глобальность, а передачу вглубь дерева компонентов без «пробрасывания пропсов» через каждый уровень. Родитель «предоставляет» значение, любой потомок ниже его «внедряет».

<script setup>
// в компоненте-родителе
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
</script>
<script setup>
// в любом потомке, как угодно глубоко
import { inject } from 'vue'
const theme = inject('theme', 'light') // второй аргумент — значение по умолчанию
</script>

Главное отличие от Pinia: provide/inject состояние привязано к поддереву. Два разных родителя могут предоставить разные значения под одним ключом — у каждого своя ветка. Это идеально для тем, локали формы, конфигурации виджета, передаваемой его внутренним частям.

Матрица выбора

СитуацияИнструмент
Состояние одного компоненталокальный ref
Переиспользуемая логика, у каждого свой экземплярcomposable
Маленькое общее состояние, без devtoolsshared composable (ref в модуле)
Передача вглубь поддереваprovide/inject
Крупное глобальное состояние, devtools, asyncPinia

Когда точно Pinia

Берите Pinia, когда состояние глобальное (нужно многим несвязанным частям), сложное (много полей, getters, async-actions) и вы хотите инструменты — отладку через devtools, плагины (персист), горячую перезагрузку. Корзина, авторизация, нотификации, кеш данных — кандидаты на store. Их объединяет то, что shared composable быстро упрётся в потолок организованности.

Как это работает под капотом

Все четыре подхода стоят на одной реактивности Vue (ref/reactive). Разница — в области владения. Локальный ref живёт в области setup компонента и собирается сборщиком мусора при размонтировании. ref в модуле живёт столько, сколько живёт модуль — то есть всё время работы приложения, поэтому он общий. provide кладёт значение в контекст экземпляра компонента, а inject идёт вверх по цепочке родителей до первого совпадения ключа — это и даёт привязку к поддереву. Pinia — надстройка, которая хранит реактивные store в едином синглтоне и добавляет devtools и плагины.

Частые ошибки

  • Класть локальное состояние в Pinia «на будущее» — лишняя глобальность и сложность тестов.
  • Путать provide/inject с глобальным store — это передача вглубь поддерева, у разных веток значения разные.
  • Объявлять «общий» ref внутри функции composable — тогда он НЕ общий, а у каждого свой; для shared его выносят в модуль.
  • Тащить Pinia в крошечный проект, где хватило бы shared composable.
  • Не задавать значение по умолчанию в inject — компонент сломается, если его используют вне нужного родителя.

Итоги

  • Выбор инструмента — это выбор области видимости: берите наименьший достаточный.
  • Одному компоненту — локальный ref; переиспользуемой логике — composable.
  • Небольшое общее состояние — shared composable (ref в модуле), без накладных расходов Pinia.
  • provide/inject — про передачу вглубь поддерева, а не про глобальность.
  • Pinia — для крупного, сложного глобального состояния, где нужны devtools, плагины и структура.
Проверьте себя
1. Какое состояние НЕ стоит хранить в Pinia?
AКорзину покупок, доступную многим компонентам
BОткрыт/закрыт ли конкретный дропдаун в одном компоненте
CДанные авторизованного пользователя
DГлобальные нотификации
2. Как сделать общее состояние без Pinia с помощью composable?
AОбъявить ref внутри функции composable
BОбъявить ref в области модуля (вне функции), тогда все импортёры получат один и тот же ref
CИспользовать provide в каждом компоненте
DЭто невозможно без Pinia
3. В чём ключевое отличие provide/inject от глобального store?
Aprovide/inject работает только с примитивами
Bprovide/inject привязывает состояние к поддереву: разные родители могут дать разные значения под одним ключом
Cprovide/inject быстрее работает
DРазницы нет, это синонимы