Back to blog
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
Share:𝕏

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 pinia
TYPESCRIPT
// 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-persistedstate
TYPESCRIPT
// 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.