Composables & Stores
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
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 syntax —
defineStore('name', () => { ... }), not the options syntax - Concurrent fetching —
Promise.all()for independent requests - Loading/error state —
isLoadinganderrorrefs in every store - Optimistic updates —
updateMemberRole()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
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:
- Current user context —
currentUserMember,isOwner,isManagerfrom the auth store - Permission checks —
canChangeRoles,canInviteMembersfrom the tenant's permission system - 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
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
| Scenario | Use |
|---|---|
| Multiple components share and mutate the same data | Pinia store + composable facade |
| Data is fetched once and consumed in one page | Direct composable with useAuthenticatedAsyncData |
| Data needs to persist across page navigation | Pinia store |
| Data is derived from other reactive sources | Composable with computed() |
| Data needs optimistic updates | Pinia 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
- Validation — How Zod schemas validate API responses at runtime
- Vertical Slices — The feature module structure
- API Contracts — The full contract system bridging backend and frontend