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.
// 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
reffor individual pieces of state andreactivefor grouped state objects. - Use
computedfor 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.
// 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;
},
},
});// 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
| Convention | Rule | Example |
|---|---|---|
| File name | {domain}.store.ts | doctor.store.ts |
| Export name | use{Domain}Store | useDoctorStore |
| Store ID (first arg) | Lowercase domain name | 'doctor' |
| Single-record stores | {domain}Single.store.ts | patientSingle.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.
// 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:
// 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:
// 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.
// 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:
reactiveinstead of individualrefs -- auth fields are always read and written together, so a single reactive object is cleaner.hydrate()-- called on app init to restore session fromsessionStorage. 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 tosessionStorageso they survive refresh.isAuthenticatedcomputed -- derived fromstate.token, used by route middleware.
Settings Store (localStorage Persistence)
The settings store persists user preferences to localStorage using the options syntax:
// 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:
// 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:
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:
// 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.
<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
| Store | File | Category | Description |
|---|---|---|---|
useAuthStore | auth.store.ts | Auth | User, token, session hydration |
useUserBranchStore | userBranch.store.ts | Auth | Branch list and selected branch |
useSettingsStore | settings.ts | Config | Theme and user preferences |
useEnumsStore | enums.store.ts | Config | Cached dropdown/enum options |
useResetPasswordStore | resetPassword.store.ts | Auth | Reset password flow state |
useProfileStore | profile.store.ts | User | User profile data |
usePatientStore | patient.store.ts | Domain list | Patient list + pagination |
usePatientSingleStore | patientSingle.store.ts | Single record | Single patient detail |
useVisitStore | visit.store.ts | Domain list | Visit list + pagination |
useVisitSingleStore | visitSingle.store.ts | Single record | Single visit detail |
useVisitPlanStore | visitPlan.store.ts | Domain list | Visit plans |
useVisitPrescribedMedicineStore | visitPrescribedMedicine.store.ts | Domain list | Prescribed medicines |
useDoctorStore | doctor.store.ts | Domain list | Doctors |
useProcedureStore | procedure.store.ts | Domain list | Procedures |
useServiceStore | service.store.ts | Domain list | Services |
useInsuranceStore | insurance.store.ts | Domain list | Insurance providers |
useBillItemsStore | billItems.store.ts | Domain list | Billing items |
useQueueStore | queue.store.ts | Domain + actions | Queue tickets with lifecycle actions |
useMedicineStore | medicine.store.ts | Domain list | Medicines |
usePharmacyItemStore | pharmacyItem.store.ts | Domain list | Pharmacy items |
usePurchaseOrderStore | purchaseOrder.store.ts | Domain list | Purchase orders |
useDeliveryStore | delivery.store.ts | Domain list | Deliveries |
useStockAvailableStore | stockAvailable.store.ts | Domain list | Available stock |
useStockMovementStore | stockMovement.store.ts | Domain list | Stock movements |
useSupplierStore | supplier.store.ts | Domain list | Suppliers |
useTransactionStore | transaction.store.ts | Domain list | Transactions |
useSurgeryScheduleStore | surgerySchedule.store.ts | Domain list | Surgery schedules |
useSurgeryLocationStore | surgeryLocation.store.ts | Domain list | Surgery locations |
useSystemCategoriesStore | systemCategories.store.ts | Domain list | System categories |
Rules Summary
- New stores must use setup syntax and the
{domain}.store.tsfile name. - Stores are data holders. Keep API calls in service classes and orchestration in composables.
- Type everything. Use typed
ref<T[]>([])andref<MetaResponse>({...}). - Return only what consumers need. Do not expose internal helpers.
- Add
reset()when state must be cleared on navigation (e.g., visit store when leaving patient detail). - 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.
