Skip to content

Composing Stores

One of Pinia's most powerful features is the ability to compose stores together. This allows you to create modular, reusable, and maintainable state management solutions by combining multiple stores.

Basic Store Composition

Using One Store in Another

You can use one store inside another by simply calling it:

typescript
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const usePostsStore = defineStore('posts', () => {
  const posts = ref([])
  const userStore = useUserStore()

  async function fetchUserPosts() {
    if (!userStore.currentUser) {
      throw new Error('User must be logged in')
    }
    
    const response = await fetch(`/api/users/${userStore.currentUser.id}/posts`)
    posts.value = await response.json()
  }

  const userPosts = computed(() => {
    return posts.value.filter(post => post.authorId === userStore.currentUser?.id)
  })

  return {
    posts: readonly(posts),
    userPosts,
    fetchUserPosts
  }
})

Cross-Store Communication

Stores can communicate with each other through shared state and actions:

typescript
// stores/notifications.ts
export const useNotificationsStore = defineStore('notifications', () => {
  const notifications = ref([])

  function addNotification(message: string, type: 'success' | 'error' | 'info' = 'info') {
    const notification = {
      id: Date.now().toString(),
      message,
      type,
      timestamp: new Date()
    }
    notifications.value.push(notification)
  }

  function removeNotification(id: string) {
    const index = notifications.value.findIndex(n => n.id === id)
    if (index > -1) {
      notifications.value.splice(index, 1)
    }
  }

  return {
    notifications: readonly(notifications),
    addNotification,
    removeNotification
  }
})

// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const user = ref(null)
  const notificationsStore = useNotificationsStore()

  async function login(credentials) {
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      
      if (response.ok) {
        user.value = await response.json()
        notificationsStore.addNotification('Successfully logged in!', 'success')
      } else {
        throw new Error('Login failed')
      }
    } catch (error) {
      notificationsStore.addNotification('Login failed. Please try again.', 'error')
      throw error
    }
  }

  return { user, login }
})

Advanced Composition Patterns

Store Factory Pattern

Create reusable store factories for similar functionality:

typescript
function createResourceStore<T>(resourceName: string, apiEndpoint: string) {
  return defineStore(`${resourceName}Store`, () => {
    const items = ref<T[]>([])
    const loading = ref(false)
    const error = ref<string | null>(null)

    async function fetchAll() {
      loading.value = true
      error.value = null
      
      try {
        const response = await fetch(apiEndpoint)
        if (!response.ok) throw new Error(`Failed to fetch ${resourceName}`)
        items.value = await response.json()
      } catch (err) {
        error.value = err instanceof Error ? err.message : 'Unknown error'
      } finally {
        loading.value = false
      }
    }

    async function create(item: Omit<T, 'id'>) {
      try {
        const response = await fetch(apiEndpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(item)
        })
        
        if (!response.ok) throw new Error(`Failed to create ${resourceName}`)
        const newItem = await response.json()
        items.value.push(newItem)
        return newItem
      } catch (err) {
        error.value = err instanceof Error ? err.message : 'Unknown error'
        throw err
      }
    }

    async function update(id: string, updates: Partial<T>) {
      try {
        const response = await fetch(`${apiEndpoint}/${id}`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(updates)
        })
        
        if (!response.ok) throw new Error(`Failed to update ${resourceName}`)
        const updatedItem = await response.json()
        
        const index = items.value.findIndex(item => item.id === id)
        if (index > -1) {
          items.value[index] = updatedItem
        }
        
        return updatedItem
      } catch (err) {
        error.value = err instanceof Error ? err.message : 'Unknown error'
        throw err
      }
    }

    async function remove(id: string) {
      try {
        const response = await fetch(`${apiEndpoint}/${id}`, {
          method: 'DELETE'
        })
        
        if (!response.ok) throw new Error(`Failed to delete ${resourceName}`)
        
        const index = items.value.findIndex(item => item.id === id)
        if (index > -1) {
          items.value.splice(index, 1)
        }
      } catch (err) {
        error.value = err instanceof Error ? err.message : 'Unknown error'
        throw err
      }
    }

    return {
      items: readonly(items),
      loading: readonly(loading),
      error: readonly(error),
      fetchAll,
      create,
      update,
      remove
    }
  })
}

// Usage
const useUsersStore = createResourceStore<User>('users', '/api/users')
const usePostsStore = createResourceStore<Post>('posts', '/api/posts')
const useCommentsStore = createResourceStore<Comment>('comments', '/api/comments')

Mixin Pattern

Create reusable functionality that can be mixed into stores:

typescript
// mixins/cacheable.ts
function useCacheable<T>(key: string, ttl: number = 5 * 60 * 1000) {
  const cache = ref<{ data: T | null; timestamp: number }>({ data: null, timestamp: 0 })

  function isCacheValid(): boolean {
    return Date.now() - cache.value.timestamp < ttl
  }

  function setCache(data: T) {
    cache.value = {
      data,
      timestamp: Date.now()
    }
  }

  function getCache(): T | null {
    return isCacheValid() ? cache.value.data : null
  }

  function clearCache() {
    cache.value = { data: null, timestamp: 0 }
  }

  return {
    cache: readonly(cache),
    isCacheValid,
    setCache,
    getCache,
    clearCache
  }
}

// mixins/loadable.ts
function useLoadable() {
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function withLoading<T>(operation: () => Promise<T>): Promise<T> {
    loading.value = true
    error.value = null
    
    try {
      const result = await operation()
      return result
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Unknown error'
      throw err
    } finally {
      loading.value = false
    }
  }

  return {
    loading: readonly(loading),
    error: readonly(error),
    withLoading
  }
}

// Using mixins in a store
export const useProductsStore = defineStore('products', () => {
  const products = ref<Product[]>([])
  const { cache, setCache, getCache, clearCache } = useCacheable<Product[]>('products')
  const { loading, error, withLoading } = useLoadable()

  async function fetchProducts() {
    const cachedProducts = getCache()
    if (cachedProducts) {
      products.value = cachedProducts
      return cachedProducts
    }

    return withLoading(async () => {
      const response = await fetch('/api/products')
      if (!response.ok) throw new Error('Failed to fetch products')
      
      const data = await response.json()
      products.value = data
      setCache(data)
      return data
    })
  }

  function invalidateCache() {
    clearCache()
  }

  return {
    products: readonly(products),
    loading,
    error,
    fetchProducts,
    invalidateCache
  }
})

Event-Driven Communication

Implement event-driven communication between stores:

typescript
// stores/events.ts
type EventCallback = (...args: any[]) => void

export const useEventBusStore = defineStore('eventBus', () => {
  const listeners = ref<Record<string, EventCallback[]>>({})

  function on(event: string, callback: EventCallback) {
    if (!listeners.value[event]) {
      listeners.value[event] = []
    }
    listeners.value[event].push(callback)

    // Return unsubscribe function
    return () => {
      const index = listeners.value[event]?.indexOf(callback)
      if (index !== undefined && index > -1) {
        listeners.value[event].splice(index, 1)
      }
    }
  }

  function emit(event: string, ...args: any[]) {
    const eventListeners = listeners.value[event]
    if (eventListeners) {
      eventListeners.forEach(callback => callback(...args))
    }
  }

  function off(event: string, callback?: EventCallback) {
    if (!callback) {
      delete listeners.value[event]
    } else {
      const index = listeners.value[event]?.indexOf(callback)
      if (index !== undefined && index > -1) {
        listeners.value[event].splice(index, 1)
      }
    }
  }

  return { on, emit, off }
})

// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])
  const eventBus = useEventBusStore()

  function addItem(product: Product, quantity: number = 1) {
    const existingItem = items.value.find(item => item.productId === product.id)
    
    if (existingItem) {
      existingItem.quantity += quantity
    } else {
      items.value.push({
        productId: product.id,
        product,
        quantity,
        addedAt: new Date()
      })
    }

    eventBus.emit('cart:item-added', { product, quantity })
  }

  function removeItem(productId: string) {
    const index = items.value.findIndex(item => item.productId === productId)
    if (index > -1) {
      const removedItem = items.value.splice(index, 1)[0]
      eventBus.emit('cart:item-removed', removedItem)
    }
  }

  return { items, addItem, removeItem }
})

// stores/analytics.ts
export const useAnalyticsStore = defineStore('analytics', () => {
  const events = ref<AnalyticsEvent[]>([])
  const eventBus = useEventBusStore()

  // Listen to cart events
  eventBus.on('cart:item-added', ({ product, quantity }) => {
    trackEvent('add_to_cart', {
      product_id: product.id,
      product_name: product.name,
      quantity,
      value: product.price * quantity
    })
  })

  eventBus.on('cart:item-removed', (item) => {
    trackEvent('remove_from_cart', {
      product_id: item.product.id,
      product_name: item.product.name,
      quantity: item.quantity
    })
  })

  function trackEvent(name: string, properties: Record<string, any>) {
    const event = {
      id: Date.now().toString(),
      name,
      properties,
      timestamp: new Date()
    }
    
    events.value.push(event)
    
    // Send to analytics service
    sendToAnalytics(event)
  }

  async function sendToAnalytics(event: AnalyticsEvent) {
    try {
      await fetch('/api/analytics', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(event)
      })
    } catch (error) {
      console.error('Failed to send analytics event:', error)
    }
  }

  return {
    events: readonly(events),
    trackEvent
  }
})

Store Dependencies

Dependency Injection Pattern

Manage store dependencies explicitly:

typescript
// stores/dependencies.ts
export interface StoreDependencies {
  apiClient: ApiClient
  logger: Logger
  cache: CacheService
}

const dependencies = ref<StoreDependencies | null>(null)

export function setStoreDependencies(deps: StoreDependencies) {
  dependencies.value = deps
}

export function getStoreDependencies(): StoreDependencies {
  if (!dependencies.value) {
    throw new Error('Store dependencies not initialized')
  }
  return dependencies.value
}

// stores/products.ts
export const useProductsStore = defineStore('products', () => {
  const products = ref<Product[]>([])
  const { apiClient, logger, cache } = getStoreDependencies()

  async function fetchProducts() {
    try {
      logger.info('Fetching products')
      
      const cached = await cache.get('products')
      if (cached) {
        products.value = cached
        return cached
      }

      const data = await apiClient.get<Product[]>('/products')
      products.value = data
      await cache.set('products', data, 300) // 5 minutes
      
      logger.info(`Fetched ${data.length} products`)
      return data
    } catch (error) {
      logger.error('Failed to fetch products:', error)
      throw error
    }
  }

  return {
    products: readonly(products),
    fetchProducts
  }
})

Hierarchical Store Structure

Create hierarchical relationships between stores:

typescript
// stores/base.ts
export function createBaseStore(name: string) {
  return defineStore(name, () => {
    const initialized = ref(false)
    const error = ref<string | null>(null)

    async function initialize() {
      if (initialized.value) return
      
      try {
        await onInitialize()
        initialized.value = true
      } catch (err) {
        error.value = err instanceof Error ? err.message : 'Initialization failed'
        throw err
      }
    }

    async function onInitialize() {
      // Override in child stores
    }

    function reset() {
      initialized.value = false
      error.value = null
    }

    return {
      initialized: readonly(initialized),
      error: readonly(error),
      initialize,
      reset,
      onInitialize
    }
  })
}

// stores/app.ts
export const useAppStore = defineStore('app', () => {
  const baseStore = createBaseStore('app')()
  const childStores = ref<string[]>([])

  async function initializeApp() {
    await baseStore.initialize()
    
    // Initialize child stores
    for (const storeName of childStores.value) {
      const store = getStoreByName(storeName)
      if (store && 'initialize' in store) {
        await store.initialize()
      }
    }
  }

  function registerChildStore(storeName: string) {
    if (!childStores.value.includes(storeName)) {
      childStores.value.push(storeName)
    }
  }

  return {
    ...baseStore,
    childStores: readonly(childStores),
    initializeApp,
    registerChildStore
  }
})

Testing Composed Stores

Mocking Store Dependencies

typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from './cart'
import { useNotificationsStore } from './notifications'

describe('Cart Store Composition', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('should notify when item is added to cart', () => {
    const cartStore = useCartStore()
    const notificationsStore = useNotificationsStore()
    
    const addNotificationSpy = vi.spyOn(notificationsStore, 'addNotification')
    
    const product = { id: '1', name: 'Test Product', price: 10 }
    cartStore.addItem(product, 2)
    
    expect(addNotificationSpy).toHaveBeenCalledWith(
      'Added Test Product to cart',
      'success'
    )
  })

  it('should handle store initialization order', async () => {
    const appStore = useAppStore()
    
    appStore.registerChildStore('cart')
    appStore.registerChildStore('notifications')
    
    await appStore.initializeApp()
    
    expect(appStore.initialized).toBe(true)
  })
})

Best Practices

1. Avoid Circular Dependencies

typescript
// ❌ Bad: Circular dependency
// store A uses store B, store B uses store A

// ✅ Good: Use a shared store or event system
export const useSharedStore = defineStore('shared', () => {
  const sharedState = ref({})
  return { sharedState }
})

2. Keep Store Responsibilities Clear

typescript
// ✅ Good: Each store has a clear responsibility
export const useUserStore = defineStore('user', () => {
  // Only user-related state and actions
})

export const useCartStore = defineStore('cart', () => {
  // Only cart-related state and actions
  const userStore = useUserStore() // OK to use other stores
})

3. Use TypeScript for Better Composition

typescript
interface StoreComposition {
  user: ReturnType<typeof useUserStore>
  cart: ReturnType<typeof useCartStore>
  notifications: ReturnType<typeof useNotificationsStore>
}

export function useStoreComposition(): StoreComposition {
  return {
    user: useUserStore(),
    cart: useCartStore(),
    notifications: useNotificationsStore()
  }
}

4. Document Store Relationships

typescript
/**
 * Shopping Cart Store
 * 
 * Dependencies:
 * - useUserStore: For user authentication state
 * - useProductsStore: For product information
 * - useNotificationsStore: For user feedback
 * 
 * Events Emitted:
 * - cart:item-added
 * - cart:item-removed
 * - cart:cleared
 */
export const useCartStore = defineStore('cart', () => {
  // Implementation
})

By following these patterns and best practices, you can create well-structured, maintainable applications with composed Pinia stores that work together seamlessly.

Released under the MIT License.