Skip to content
SaaS4Builders
Frontend

Composables & Stores

Pinia stores with composition API, the facade pattern, useAuthenticatedAsyncData, and real examples from billing and team features.

The frontend uses two complementary patterns for reactive state: Pinia stores for shared domain state and composables as thin facades that enrich store data with computed helpers and actions. Pages and components interact with composables, not stores directly.


The Two-Layer Pattern

Page / Component
    ↓
Composable (facade)          ← Enriches data, adds computed helpers
    ↓
Pinia Store (state)          ← Manages raw data, loading, errors
    ↓
API Module (useXxxApi)       ← HTTP calls, Zod validation
    ↓
common/api/client.ts         ← CSRF, tenant headers, case transforms

Not every feature needs a Pinia store. Simple features use composables directly with useAuthenticatedAsyncData(). The store pattern is for features where multiple components need to share and mutate the same state (e.g., team members).


Pinia Stores

Stores use the Composition API syntax (not Options API). They manage raw state, expose computed properties, and provide fetch/mutate actions.

Real Example: Team Store

frontend/features/core/team/stores/useTeamStore.ts
export const useTeamStore = defineStore('team', () => {
  const api = useTeamApi()

  // ─── State ────────────────────────────────────────────────────────
  const members = ref<TeamMember[]>([])
  const invitations = ref<Invitation[]>([])
  const roles = ref<RoleObject[]>([])
  const stats = ref<TeamStats | null>(null)
  const isLoading = ref(false)
  const error = ref<string | null>(null)

  // ─── Computed ─────────────────────────────────────────────────────
  const owner = computed(() =>
    members.value.find((m) => m.role.name === 'owner') ?? null
  )

  const pendingInvitations = computed(() =>
    invitations.value.filter((i) => i.status === 'pending' && i.isValid)
  )

  const canAddMembers = computed(() => {
    if (!stats.value) return false
    const limit = stats.value.limit
    if (limit === null) return true   // Unlimited
    if (limit === 0) return false     // No seats
    return (stats.value.available ?? 0) > 0
  })

  // ─── Actions ──────────────────────────────────────────────────────
  async function fetchAll(): Promise<void> {
    isLoading.value = true
    error.value = null
    try {
      const [fetchedMembers, fetchedInvitations, fetchedStats] = await Promise.all([
        api.listMembers(),
        api.listInvitations(),
        api.getStats(),
      ])
      members.value = fetchedMembers
      invitations.value = fetchedInvitations
      stats.value = fetchedStats
      await fetchRoles()
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to fetch team data'
    } finally {
      isLoading.value = false
    }
  }

  function clear(): void {
    members.value = []
    invitations.value = []
    roles.value = []
    stats.value = null
    isLoading.value = false
    error.value = null
  }

  return {
    members, invitations, roles, stats, isLoading, error,
    owner, pendingInvitations, canAddMembers,
    fetchAll, fetchMembers, fetchInvitations, fetchRoles, fetchStats,
    updateMemberRole, clear,
  }
})

Store Conventions

  • Composition API syntaxdefineStore('name', () => { ... }), not the options syntax
  • Concurrent fetchingPromise.all() for independent requests
  • Loading/error stateisLoading and error refs in every store
  • Optimistic updatesupdateMemberRole() modifies local state before the API call confirms
  • Clear method — For logout or tenant switch, reset all state
  • No readonly() on state refs — avoids Pinia SSR hydration warnings

Composable Facades

Composables wrap stores (or API calls directly) and add computed helpers, permission checks, and enriched data. They are the primary interface for pages and components.

Real Example: useTeamMembers

frontend/features/core/team/composables/useTeamMembers.ts
export function useTeamMembers(): UseTeamMembersReturn {
  const store = useTeamStore()
  const authStore = useAuthStore()
  const { hasTenantPermission } = useCurrentTenant()

  // ─── State (from store) ──────────────────────────────────────────
  const members = toRef(store, 'members')
  const isLoading = toRef(store, 'isLoading')
  const error = toRef(store, 'error')
  const stats = toRef(store, 'stats')

  // ─── Computed Helpers ────────────────────────────────────────────
  const currentUserMember = computed<TeamMember | null>(() => {
    const userId = authStore.user?.id
    if (!userId) return null
    return members.value.find((m) => m.id === userId) ?? null
  })

  const isOwner = computed(() => currentUserRole.value === 'owner')
  const isManager = computed(() => {
    const role = currentUserRole.value
    return role === 'owner' || role === 'admin'
  })

  const canChangeRoles = computed(() => hasTenantPermission('roles.manage'))
  const canInviteMembers = computed(() => isManager.value)

  // ─── Per-Member Enrichment ───────────────────────────────────────
  function enrichMember(member: TeamMember): EnrichedTeamMember {
    const isSelf = member.id === authStore.user?.id
    const memberIsOwner = member.role.name === 'owner'

    return {
      ...member,
      isSelf,
      isOwner: memberIsOwner,
      canBeRemoved: !isSelf && !memberIsOwner && canRemoveMembers.value,
      canRoleBeChanged: !isSelf && !memberIsOwner && canChangeRoles.value,
      roleColor: ROLE_COLORS[member.role.name as keyof typeof ROLE_COLORS],
    }
  }

  const enrichedMembers = computed(() => members.value.map(enrichMember))

  return {
    members, isLoading, error, stats,
    currentUserMember, currentUserRole, isOwner, isManager,
    canChangeRoles, canRemoveMembers, canInviteMembers,
    enrichedMembers, enrichMember,
    fetchMembers: () => store.fetchMembers(),
    fetchAll: () => store.fetchAll(),
  }
}

The composable adds three things the store does not have:

  1. Current user contextcurrentUserMember, isOwner, isManager from the auth store
  2. Permission checkscanChangeRoles, canInviteMembers from the tenant's permission system
  3. Enriched members — Each member decorated with canBeRemoved, canRoleBeChanged, roleColor

Pages use useTeamMembers(), not useTeamStore() directly.


Direct Composables (No Store)

Not every feature needs a store. When state is only used in one place, use useAuthenticatedAsyncData() directly in a composable.

Real Example: useSubscription

frontend/features/core/docs/billing/composables/useSubscription.ts
export function useSubscription(): UseSubscriptionReturn {
  const api = useBillingApi()

  const { data, status, error, refresh } = useAuthenticatedAsyncData(
    'billing:subscription',
    () => api.getSubscription({ include: ['plan', 'plan.features'] })
  )

  // Computed helpers derived from the data
  const subscription = computed(() => data.value ?? null)
  const plan = computed(() => subscription.value?.plan ?? null)
  const isActive = computed(() =>
    isStatusInList(subscription.value?.status, ACTIVE_STATUSES)
  )
  const canCancel = computed(() =>
    isStatusInList(subscription.value?.status, CANCELLABLE_STATUSES)
      && !subscription.value?.cancelAtPeriodEnd
  )
  const daysRemaining = computed(() =>
    calculateDaysRemaining(subscription.value?.currentPeriodEnd)
  )

  // UI helpers
  const statusLabel = computed(() =>
    SUBSCRIPTION_STATUS_LABELS[subscription.value?.status ?? ''] ?? ''
  )
  const statusColor = computed(() =>
    SUBSCRIPTION_STATUS_COLORS[subscription.value?.status ?? ''] ?? 'neutral'
  )

  // Actions
  async function cancel(input?: CancelSubscriptionInput): Promise<Subscription> {
    const result = await api.cancelSubscription(input)
    await refresh()
    return result
  }

  async function resume(): Promise<Subscription> {
    const result = await api.resumeSubscription()
    await refresh()
    return result
  }

  return {
    subscription, plan, isActive, canCancel, canResume, hasProblem,
    daysRemaining, trialDaysRemaining, statusLabel, statusColor,
    isLoading: computed(() => status.value === 'pending'),
    status, error,
    cancel, resume, refresh,
  }
}

No store needed — useAuthenticatedAsyncData handles caching, deduplication, and reactive updates. The composable adds computed helpers and action methods.


SSR-Safe Data Fetching

All authenticated API calls must use useAuthenticatedAsyncData() instead of raw useAsyncData. This is critical for SSR correctness.

// ❌ WRONG — will silently return null during SSR
const { data } = useAsyncData('billing:sub', () => api.getSubscription())

// ✅ CORRECT — forces server: false, fetches on client only
const { data } = useAuthenticatedAsyncData('billing:sub', () => api.getSubscription())

Why: Sanctum cookies and tenant context are not available during server-side rendering. Raw useAsyncData would execute on the server, get a null response (no auth), serialize it into the payload, and the client would not re-fetch — leaving the user with empty data.

useAuthenticatedAsyncData sets server: false internally, ensuring the fetch happens only on the client where auth cookies are available.

Namespaced Keys

All useAsyncData keys use a namespaced format to prevent collisions:

useAuthenticatedAsyncData('billing:subscription', () => ...)
useAuthenticatedAsyncData('billing:invoices', () => ...)
useAuthenticatedAsyncData('team:members', () => ...)
useAuthenticatedAsyncData('catalog:plans', () => ...)

When to Use a Store vs a Composable

ScenarioUse
Multiple components share and mutate the same dataPinia store + composable facade
Data is fetched once and consumed in one pageDirect composable with useAuthenticatedAsyncData
Data needs to persist across page navigationPinia store
Data is derived from other reactive sourcesComposable with computed()
Data needs optimistic updatesPinia store (mutate state immediately, revert on error)

In the codebase: the team feature uses a store (members are shared across multiple components and need optimistic updates). The billing feature's subscription composable does not need a store — it is fetched and consumed in one context.


What's Next