Композиция stores

Композиция stores — ключевая идея современной архитектуры состояния во Vue.js, особенно в экосистеме Vue 3 и Pinia. Вместо монолитных глобальных хранилищ используется разбиение логики на независимые, переиспользуемые и легко тестируемые единицы. Каждый store инкапсулирует конкретную предметную область, а композиция позволяет связывать их между собой без жёстких зависимостей.

Такой подход основан на принципах Composition API: состояние, вычисления и побочные эффекты группируются по смыслу, а не по типу.


Pinia как основа композиции

Pinia — официальный менеджер состояния для Vue 3, полностью совместимый с Composition API. Store в Pinia по своей природе является функцией, что делает композицию естественной.

Пример базового store:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  const user = ref(null)

  const isAuthenticated = computed(() => !!user.value)

  function setUser(data) {
    user.value = data
  }

  return {
    user,
    isAuthenticated,
    setUser
  }
})

Store здесь — это не объект, а композиционная функция, возвращающая состояние и методы.


Использование одного store внутри другого

Композиция stores означает, что один store может использовать другой напрямую, вызывая его как обычную функцию. Это не создаёт глобальной зависимости, так как Pinia управляет единственным экземпляром.

import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useUserStore } from './user'

export const usePermissionsStore = defineStore('permissions', () => {
  const userStore = useUserStore()

  const canEdit = computed(() => {
    return userStore.user?.role === 'admin'
  })

  return {
    canEdit
  }
})

Ключевые особенности:

  • store вызывается внутри тела другого store
  • реактивность сохраняется автоматически
  • отсутствует необходимость в явной синхронизации

Избежание циклических зависимостей

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

Неправильный пример:

  • useUserStore использует usePermissionsStore
  • usePermissionsStore использует useUserStore

Корректные подходы:

  • вынесение общей логики в отдельный store
  • передача данных через аргументы функций
  • использование composables вместо store

Store и composables: разделение ответственности

Композиция stores не означает, что вся логика должна находиться в них. Чистая бизнес-логика, не зависящая от глобального состояния, выносится в composables.

// useDateFormatter.js
export function useDateFormatter() {
  function format(date) {
    return new Intl.DateTimeFormat('ru-RU').format(date)
  }

  return { format }
}

Использование в store:

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useDateFormatter } from '@/composables/useDateFormatter'

export const useLogStore = defineStore('log', () => {
  const logs = ref([])
  const { format } = useDateFormatter()

  function addLog(message) {
    logs.value.push({
      message,
      date: format(new Date())
    })
  }

  return { logs, addLog }
})

Такой подход делает store компактными и сфокусированными.


Композиция асинхронной логики

Асинхронные действия — важная часть state-менеджмента. В композиционной архитектуре они легко комбинируются между stores.

export const usePostsStore = defineStore('posts', () => {
  const posts = ref([])
  const loading = ref(false)

  async function fetchPosts() {
    loading.value = true
    posts.value = await api.getPosts()
    loading.value = false
  }

  return { posts, loading, fetchPosts }
})

Использование в другом store:

export const useDashboardStore = defineStore('dashboard', () => {
  const postsStore = usePostsStore()

  const postCount = computed(() => postsStore.posts.length)

  return { postCount }
})

Асинхронность не нарушает композицию, так как реактивность основана на ссылках (ref).


Совместное состояние без дублирования

Композиция stores позволяет избегать копирования состояния. Один store владеет данными, другие — только читают или реагируют.

Принципы:

  • один источник истины
  • отсутствие watch для синхронизации между stores
  • вычисляемые свойства вместо копий

Инъекция зависимостей через аргументы

Иногда store должен быть более универсальным. В таких случаях используется параметризация.

export const useCounterStore = defineStore('counter', () => {
  function createCounter(initial = 0) {
    const count = ref(initial)
    const inc = () => count.value++
    return { count, inc }
  }

  return { createCounter }
})

Этот приём позволяет использовать store как фабрику реактивных сущностей.


Тестируемость композиционных stores

Композиция повышает тестируемость за счёт:

  • отсутствия скрытых побочных эффектов
  • явных зависимостей
  • использования чистых функций

Store можно тестировать изолированно, подменяя зависимые composables или другие stores.

const userStore = useUserStore()
userStore.setUser({ role: 'admin' })

const permissionsStore = usePermissionsStore()
expect(permissionsStore.canEdit).toBe(true)

Масштабирование и модульность

При росте приложения количество stores увеличивается, но композиционный подход предотвращает хаос.

Рекомендуемая структура:

stores/
 ├─ user/
 │   ├─ user.store.js
 │   └─ permissions.store.js
 ├─ posts/
 │   └─ posts.store.js
 └─ ui/
     └─ modal.store.js

Каждый модуль может содержать несколько связанных stores, использующих друг друга через композицию, но не пересекающихся с другими доменами.


Связь композиции stores и Composition API

Store в Pinia — это частный случай composable. Он подчиняется тем же правилам:

  • вызывается внутри setup-контекста
  • возвращает реактивные значения
  • может быть скомбинирован с другими функциями

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