Skip to content

Last updated:

Pinia Store Patterns

All Pinia stores live in app/stores/ and follow consistent conventions. This page covers the two store syntaxes used in CPR, naming rules, and the standard patterns for each store category.

Setup Syntax (Default)

The vast majority of CPR stores use the setup syntax (composition API). This is the preferred style for new stores.

ts
// app/stores/doctor.store.ts
import { defineStore } from 'pinia';
import type { APIPagination } from '~/types/apiReponse.type';
import type { Doctor } from '~/types/doctor.type';

export const useDoctorStore = defineStore('doctor', () => {
  const list = ref<Doctor[]>([]);
  const pagination = ref<APIPagination>({
    per_page: 15,
    total: 0,
    from: 0,
    to: 0,
    current_page: 1,
  });

  return {
    list,
    pagination,
  };
});

Key rules:

  • The second argument is a setup function (arrow function returning an object), not an options object.
  • Use ref for individual pieces of state and reactive for grouped state objects.
  • Use computed for derived values.
  • Export plain functions for actions -- they are just regular functions that mutate refs.
  • Everything returned from the function is exposed to consumers.

Options Syntax (Exceptions)

A few stores use the options syntax when they need heavier action logic or when the store was created before the setup convention was established. The two primary examples are useEnumsStore and useQueueStore.

ts
// app/stores/enums.store.ts
export const useEnumsStore = defineStore('enums', {
  state: (): EnumsState => ({
    status: [],
    types: [],
    units: [],
    // ...many enum arrays
    loading: false,
    error: null,
    loaded: false,
  }),

  actions: {
    async fetchEnums(force = false) {
      if (this.loaded && !force) return;
      this.loading = true;
      // ...fetch and assign
      this.loaded = true;
    },
  },
});
ts
// app/stores/queue.store.ts
export const useQueueStore = defineStore('queue', {
  state: () => ({
    list: [] as QueueTicket[],
    pagination: {} as APIPagination,
    currentPage: 1,
    loading: false,
    error: null as { message: string; details: string } | null,
    activeServiceId: null as number | null,
  }),

  actions: {
    async fetchActiveQueue(branchServiceId: number, ...) { /* ... */ },
    async callTicket(id: number) { /* ... */ },
    async serveTicket(id: number) { /* ... */ },
    // ...more ticket lifecycle actions
  },
});

For new stores, prefer setup syntax unless you have a strong reason to use options.

Naming Conventions

ConventionRuleExample
File name{domain}.store.tsdoctor.store.ts
Export nameuse{Domain}StoreuseDoctorStore
Store ID (first arg)Lowercase domain name'doctor'
Single-record stores{domain}Single.store.tspatientSingle.store.ts

The settings.ts store is the only file that omits the .store.ts suffix. New stores must include it.

Store Categories

Domain List Stores

The most common pattern. Holds a typed array and pagination metadata for a paginated API resource. The store is a pure data holder -- no API calls, no business logic.

ts
// app/stores/patient.store.ts
import { defineStore } from 'pinia';
import type { MetaResponse } from '~/types/apiReponse.type';
import type { Patient } from '~/types/patient.type';

export const usePatientStore = defineStore('patient', () => {
  const list = ref<Patient[]>([]);
  const pagination = ref<MetaResponse>({
    per_page: 15,
    total: 0,
    from: 0,
    to: 0,
  });

  return {
    list,
    pagination,
  };
});

This same list + pagination shape is used by: billItems, delivery, doctor, insurance, medicine, patient, pharmacyItem, procedure, purchaseOrder, service, stockAvailable, stockMovement, supplier, surgeryLocation, surgerySchedule, systemCategories, transaction, visit, visitPlan, and visitPrescribedMedicine.

Some domain stores add a reset() method to clear state when navigating away:

ts
// app/stores/visit.store.ts
export const useVisitStore = defineStore('visit', () => {
  const list = ref<PatientVisit[]>([]);
  const pagination = ref<MetaResponse>({
    per_page: 15,
    total: 0,
    from: 0,
    to: 0,
  });

  const reset = () => {
    list.value = [];
    pagination.value = { per_page: 15, total: 0, from: 0, to: 0 };
  };

  return { list, pagination, reset };
});

Single-Record Stores

When a page needs a single domain record (e.g., patient detail view), a dedicated *Single store holds that record:

ts
// app/stores/patientSingle.store.ts
import { defineStore } from 'pinia';
import type { Patient } from '~/types/patient.type';

export const usePatientSingleStore = defineStore('patientSingle', () => {
  const loading = ref(false);
  const patientData = ref<Patient | null>(null);

  return {
    loading,
    patientData,
  };
});

Auth Store (Session Persistence)

The auth store is unique. It uses reactive to group related auth fields and provides hydrate/reset methods for session lifecycle.

ts
// app/stores/auth.store.ts
interface AuthState {
  user: AuthUser | null;
  token: string | null;
  resetCodeVerified?: boolean;
  branchServices: { id: number; name: string; code: string }[] | null;
}

export const useAuthStore = defineStore('auth', () => {
  const authBranchStore = useUserBranchStore();

  const state = reactive<AuthState>({
    user: null,
    token: null,
    resetCodeVerified: false,
    branchServices: null,
  });

  const isAuthenticated = computed(() => !!state.token);

  const hydrate = () => {
    const storedAuth = sessionStorage.getItem('auth');
    if (storedAuth) {
      try {
        const storedStated: AuthState = JSON.parse(storedAuth);
        state.user = storedStated.user;
        state.token = storedStated.token;
        state.resetCodeVerified = storedStated.resetCodeVerified;
        state.branchServices = storedStated.user?.branch_services || [];
        authBranchStore.selectedBranch =
          state.user?.default_branch || null;
      } catch (e) {
        state.user = null;
        state.token = null;
        state.resetCodeVerified = false;
      }
    }
  };

  const reset = () => {
    state.user = null;
    state.token = null;
    state.resetCodeVerified = false;
  };

  const updateBranch = (branch: Branch) => {
    const storedAuth = sessionStorage.getItem('auth');
    if (storedAuth) {
      const storedStated: AuthState = JSON.parse(storedAuth);
      if (storedStated.user?.default_branch) {
        storedStated.user.default_branch = branch;
        sessionStorage.setItem('auth', JSON.stringify(storedStated));
      }
    }
  };

  return { state, isAuthenticated, hydrate, reset, updateBranch };
});

Key design decisions:

  • reactive instead of individual refs -- auth fields are always read and written together, so a single reactive object is cleaner.
  • hydrate() -- called on app init to restore session from sessionStorage. This keeps the user logged in across page refreshes within the same tab.
  • reset() -- called on logout to clear all auth state.
  • updateBranch() -- syncs branch changes back to sessionStorage so they survive refresh.
  • isAuthenticated computed -- derived from state.token, used by route middleware.

Settings Store (localStorage Persistence)

The settings store persists user preferences to localStorage using the options syntax:

ts
// app/stores/settings.ts
export const useSettingsStore = defineStore('settings', {
  state: (): SettingsState => ({
    theme: 'system',
    defaultBranch: null,
    defaultLandingPage: '/dashboard',
    twoFactorEnabled: false,
  }),

  actions: {
    setTheme(theme: Theme) {
      this.theme = theme;
      this.applyTheme();
      localStorage.setItem('theme', theme);
    },

    loadSettings() {
      const savedTheme = localStorage.getItem('theme') as Theme | null;
      if (savedTheme) this.theme = savedTheme;
      // ...load other saved preferences
      this.applyTheme();
    },
  },
});

Branch Store

Holds the list of branches available to the user and the currently selected branch:

ts
// app/stores/userBranch.store.ts
export const useUserBranchStore = defineStore('userBranch', () => {
  const branches = ref<Branch[]>([]);
  const selectedBranch = ref<Branch | null>(null);

  return { branches, selectedBranch };
});

The auth store writes to selectedBranch during hydration. Components read selectedBranch to scope API calls to the active clinic.

Enum Store (Cached API Data)

The enum store fetches all dropdown options once and caches them. It uses a loaded flag to skip redundant requests:

ts
async fetchEnums(force = false) {
  if (this.loaded && !force) return;  // skip if already loaded
  this.loading = true;
  // ...fetch from /enums endpoint
  this.loaded = true;
}

Other stores and composables read enum values for select dropdowns, filter options, and status labels throughout the app.

How Composables Interact with Stores

Stores are read targets and write targets. They do not call APIs themselves (with the exception of useEnumsStore and useQueueStore). The standard flow is:

ts
// app/composables/doctor/useDoctor.ts
export const useDoctor = () => {
  const doctorStore = useDoctorStore();

  const loading = ref(false);
  const error = ref('');
  const success = ref(false);

  const getDoctors = async (search?, filters?, page?) => {
    loading.value = true;
    try {
      const paginatedDoctors = await doctorService.getDoctors(filters);
      const { data, ...pagination } = paginatedDoctors;

      // Write to the store
      doctorStore.list = data;
      doctorStore.pagination = pagination;

      success.value = true;
    } catch (err) {
      error.value = /* extract message */;
      success.value = false;
    } finally {
      loading.value = false;
    }
  };

  return { loading, error, success, getDoctors };
};

The component calls getDoctors(), which fetches data via the service and writes the result into useDoctorStore. Any component that reads doctorStore.list will reactively update.

vue
<script setup lang="ts">
const doctorStore = useDoctorStore();
const { loading, getDoctors } = useDoctor();

onMounted(() => getDoctors());
</script>

<template>
  <div v-if="loading">Loading...</div>
  <DoctorTable v-else :doctors="doctorStore.list" />
  <UiPagination :meta="doctorStore.pagination" @page-change="..." />
</template>

Complete List of Stores

StoreFileCategoryDescription
useAuthStoreauth.store.tsAuthUser, token, session hydration
useUserBranchStoreuserBranch.store.tsAuthBranch list and selected branch
useSettingsStoresettings.tsConfigTheme and user preferences
useEnumsStoreenums.store.tsConfigCached dropdown/enum options
useResetPasswordStoreresetPassword.store.tsAuthReset password flow state
useProfileStoreprofile.store.tsUserUser profile data
usePatientStorepatient.store.tsDomain listPatient list + pagination
usePatientSingleStorepatientSingle.store.tsSingle recordSingle patient detail
useVisitStorevisit.store.tsDomain listVisit list + pagination
useVisitSingleStorevisitSingle.store.tsSingle recordSingle visit detail
useVisitPlanStorevisitPlan.store.tsDomain listVisit plans
useVisitPrescribedMedicineStorevisitPrescribedMedicine.store.tsDomain listPrescribed medicines
useDoctorStoredoctor.store.tsDomain listDoctors
useProcedureStoreprocedure.store.tsDomain listProcedures
useServiceStoreservice.store.tsDomain listServices
useInsuranceStoreinsurance.store.tsDomain listInsurance providers
useBillItemsStorebillItems.store.tsDomain listBilling items
useQueueStorequeue.store.tsDomain + actionsQueue tickets with lifecycle actions
useMedicineStoremedicine.store.tsDomain listMedicines
usePharmacyItemStorepharmacyItem.store.tsDomain listPharmacy items
usePurchaseOrderStorepurchaseOrder.store.tsDomain listPurchase orders
useDeliveryStoredelivery.store.tsDomain listDeliveries
useStockAvailableStorestockAvailable.store.tsDomain listAvailable stock
useStockMovementStorestockMovement.store.tsDomain listStock movements
useSupplierStoresupplier.store.tsDomain listSuppliers
useTransactionStoretransaction.store.tsDomain listTransactions
useSurgeryScheduleStoresurgerySchedule.store.tsDomain listSurgery schedules
useSurgeryLocationStoresurgeryLocation.store.tsDomain listSurgery locations
useSystemCategoriesStoresystemCategories.store.tsDomain listSystem categories

Rules Summary

  1. New stores must use setup syntax and the {domain}.store.ts file name.
  2. Stores are data holders. Keep API calls in service classes and orchestration in composables.
  3. Type everything. Use typed ref<T[]>([]) and ref<MetaResponse>({...}).
  4. Return only what consumers need. Do not expose internal helpers.
  5. Add reset() when state must be cleared on navigation (e.g., visit store when leaving patient detail).
  6. Avoid duplicating state. If two stores need the same data, one should read from the other or the data should live in a shared store.

CPR - Clinical Patient Records