Frontend Engineeringintermediate
State Management with Pinia in Vue 3
Master Pinia — Vue 3's official state management library. Define stores with the Composition API style, handle async actions, persist state to localStorage, use store-to-store composition, and test stores with Vitest.
LearnixoApril 16, 20266 min read
Vue.jsPiniaState ManagementVue 3TypeScriptFrontend
Why Pinia?
Pinia is the official Vue 3 state management library — the successor to Vuex. Key advantages:
- Composition API style — stores look like composables
- Full TypeScript support — no more type gymnastics
- No mutations — just state + actions (simpler mental model)
- DevTools — time-travel debugging built in
- Modular — each store is independent, no global namespace
Setup
Bash
npm install piniaTYPESCRIPT
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')Defining a Store
Pinia supports both Options API style and Setup function style. Use Setup style — it feels like a composable:
TYPESCRIPT
// src/stores/appointments.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Appointment, AppointmentRequest } from '@/types'
import { getAppointments, createAppointment, updateAppointmentStatus } from '@/lib/api'
export const useAppointmentStore = defineStore('appointments', () => {
// STATE — reactive variables
const appointments = ref<Appointment[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const selectedId = ref<string | null>(null)
// GETTERS — computed from state
const scheduledCount = computed(() =>
appointments.value.filter(a => a.status === 'scheduled').length
)
const selectedAppointment = computed(() =>
appointments.value.find(a => a.id === selectedId.value) ?? null
)
const byDate = computed(() => {
return [...appointments.value].sort(
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
)
})
// ACTIONS — functions that modify state
async function fetchAll(clinicId: string, date?: string) {
loading.value = true
error.value = null
try {
appointments.value = await getAppointments(clinicId, date)
} catch (e: any) {
error.value = e.message ?? 'Failed to load appointments'
} finally {
loading.value = false
}
}
async function create(request: AppointmentRequest): Promise<Appointment> {
const created = await createAppointment(request)
appointments.value.unshift(created) // add to front of list
return created
}
async function cancel(id: string): Promise<void> {
await updateAppointmentStatus(id, 'cancelled')
const appt = appointments.value.find(a => a.id === id)
if (appt) appt.status = 'cancelled' // optimistic update
}
function select(id: string | null) {
selectedId.value = id
}
function $reset() {
appointments.value = []
loading.value = false
error.value = null
selectedId.value = null
}
return {
// State
appointments, loading, error, selectedId,
// Getters
scheduledCount, selectedAppointment, byDate,
// Actions
fetchAll, create, cancel, select, $reset,
}
})Using Stores in Components
VUE
<script setup lang="ts">
import { onMounted, storeToRefs } from 'vue'
import { useAppointmentStore } from '@/stores/appointments'
const store = useAppointmentStore()
// storeToRefs — destructure while keeping reactivity
// (plain destructuring breaks reactivity!)
const { appointments, loading, error, scheduledCount } = storeToRefs(store)
// Actions can be destructured directly (not refs)
const { fetchAll, cancel, select } = store
onMounted(() => fetchAll('CLN-001'))
</script>
<template>
<div>
<p>{{ scheduledCount }} upcoming</p>
<div v-if="loading" class="spinner" />
<div v-else-if="error" class="error">{{ error }}</div>
<ul v-else>
<li
v-for="appt in appointments"
:key="appt.id"
@click="select(appt.id)"
>
{{ appt.patientName }} — {{ appt.dateTime }}
</li>
</ul>
</div>
</template>Store Composition
Stores can use other stores:
TYPESCRIPT
// src/stores/dashboard.ts
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useAppointmentStore } from './appointments'
import { useCallStore } from './calls'
import { useClinicStore } from './clinic'
export const useDashboardStore = defineStore('dashboard', () => {
const appointments = useAppointmentStore()
const calls = useCallStore()
const clinic = useClinicStore()
const summary = computed(() => ({
clinicName: clinic.currentClinic?.name ?? '',
scheduledToday: appointments.scheduledCount,
callsHandled: calls.todayMetrics?.callsHandled ?? 0,
agentsAvailable: calls.realtimeMetrics?.agentsAvailable ?? 0,
}))
async function loadAll(clinicId: string) {
await Promise.all([
appointments.fetchAll(clinicId),
calls.fetchTodayMetrics(clinicId),
calls.fetchRealtimeMetrics(clinicId),
])
}
return { summary, loadAll }
})Persisting State
Use pinia-plugin-persistedstate to survive page refreshes:
Bash
npm install pinia-plugin-persistedstateTYPESCRIPT
// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)TYPESCRIPT
// src/stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
// ...actions
return { user, token }
}, {
persist: {
key: 'portal-auth',
storage: localStorage,
pick: ['token'], // only persist the token, not the user object
}
})Auth Store — Full Example
TYPESCRIPT
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { signIn, signOut, getCurrentUser, fetchAuthSession } from 'aws-amplify/auth'
export interface User {
userId: string
email: string
clinicId: string | null
groups: string[]
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => user.value !== null)
const isAdmin = computed(() => user.value?.groups.includes('admin') ?? false)
const clinicId = computed(() => user.value?.clinicId ?? null)
async function initialize() {
try {
const session = await fetchAuthSession()
const claims = session.tokens?.idToken?.payload
if (claims) {
user.value = {
userId: claims.sub as string,
email: claims.email as string,
clinicId: (claims['custom:clinic_id'] as string) ?? null,
groups: ((claims['cognito:groups'] as string) ?? '').split(',').filter(Boolean),
}
}
} catch {
user.value = null
}
}
async function login(email: string, password: string) {
loading.value = true
error.value = null
try {
await signIn({ username: email, password })
await initialize()
} catch (e: any) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function logout() {
await signOut()
user.value = null
// Reset other stores
useAppointmentStore().$reset()
}
return { user, loading, error, isAuthenticated, isAdmin, clinicId, initialize, login, logout }
})Testing Pinia Stores
TYPESCRIPT
// src/stores/__tests__/appointments.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { useAppointmentStore } from '../appointments'
import * as api from '@/lib/api'
vi.mock('@/lib/api')
describe('useAppointmentStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('fetchAll sets appointments on success', async () => {
const mockAppts = [{ id: 'APT-1', patientName: 'Jane', status: 'scheduled' }]
vi.mocked(api.getAppointments).mockResolvedValue(mockAppts as any)
const store = useAppointmentStore()
await store.fetchAll('CLN-001')
expect(store.appointments).toEqual(mockAppts)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('fetchAll sets error on failure', async () => {
vi.mocked(api.getAppointments).mockRejectedValue(new Error('Network error'))
const store = useAppointmentStore()
await store.fetchAll('CLN-001')
expect(store.appointments).toHaveLength(0)
expect(store.error).toBe('Network error')
})
it('cancel updates appointment status optimistically', async () => {
const store = useAppointmentStore()
store.appointments = [{ id: 'APT-1', status: 'scheduled' } as any]
vi.mocked(api.updateAppointmentStatus).mockResolvedValue(undefined)
await store.cancel('APT-1')
expect(store.appointments[0].status).toBe('cancelled')
})
it('scheduledCount returns correct count', () => {
const store = useAppointmentStore()
store.appointments = [
{ id: '1', status: 'scheduled' },
{ id: '2', status: 'cancelled' },
{ id: '3', status: 'scheduled' },
] as any
expect(store.scheduledCount).toBe(2)
})
})Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.