Best Practices
Practical patterns and conventions for building features in the CPR frontend. These are drawn from the existing codebase and should be followed for all new work.
Composables
One Concern Per Composable
Each composable should do one thing. For CRUD domains, split into separate composables.
| Composable | Responsibility |
|---|---|
useDoctor | Fetch list, populate store |
useCreateDoctor | Create a single doctor |
useUpdateDoctor | Update a single doctor |
useDeleteDoctor | Delete a single doctor |
app/composables/doctor/
useDoctor.ts # list / fetch
useCreateDoctor.ts # create
useUpdateDoctor.ts # update
useDeleteDoctor.ts # deleteComposable Return Shape
Every async composable must return loading, error, success, and the action function. This makes consumption in components consistent.
export const useCreateDoctor = () => {
const success = ref(false);
const loading = ref(false);
const error = ref('');
const createDoctor = async (params: Partial<Doctor>) => {
loading.value = true;
success.value = false;
error.value = '';
try {
await doctorService.createDoctor(params);
success.value = true;
} catch (err) {
const message =
err instanceof Error ? err.message : 'Something went wrong';
error.value = message;
success.value = false;
} finally {
loading.value = false;
}
};
return { loading, error, success, createDoctor };
};Composables Call Services, Not useApiFetch Directly
Composables should call service class methods. The service layer owns the HTTP details and Sentry reporting. This keeps composables focused on component-level state management.
// good - composable delegates to service
const paginatedDoctors = await doctorService.getDoctors(filters);
// avoid - composable making raw API calls
const res = await useApiFetch<PaginatedResourceResponse<Doctor>>('/doctors', { ... });State Management
When to Use What
| Pattern | Use when |
|---|---|
| Pinia store | Multiple components need the same data (e.g., doctorStore.list used by table + filters + pagination) |
| Composable | Logic is reused across pages but state is per-instance (e.g., useCreateDoctor) |
| Module-level refs | Singleton state shared app-wide without Pinia overhead (e.g., useDialog, useSnackBar, useConfirmDialog) |
| Local state | State belongs to a single component and is not shared |
Pinia Store Pattern
Stores hold domain data and pagination. Keep them simple -- no business logic, no API calls. Let composables populate them.
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 };
});Module-Level Shared State
For singletons like snackbar or dialog, declare reactive state at the module level (outside the function). Every call to the composable returns the same refs.
// State lives at module level - shared across all consumers
const visible = ref(false);
const message = ref('');
const type = ref<SnackBarType>('success');
export function useSnackBar() {
const show = (msg: string, msgType: SnackBarType = 'success', duration = 3000) => {
message.value = msg;
type.value = msgType;
visible.value = true;
};
return { message, type, visible, show, close };
}List Pages
Always Use useListFilters
Every paginated list page must use useListFilters. It provides URL-synced filters, debounced search, pagination, and a consistent API.
const { filters, currentPage, setPage, filter, refetchCurrentPage } =
useListFilters({
initialFilters: {
search: '',
status: '',
},
onFilter: async (params) => {
await doctor.getDoctors(params.search, { status: params.status }, params.page);
},
});
// Watch non-search filters for immediate triggering
watchImmediateFilter(['status']);
// Fetch on mount
onMounted(async () => {
await filter();
});Page Change Handler
Delegate pagination to setPage -- it updates the URL query, triggers the API call, and keeps everything in sync.
const handlePageChange = async (page: number) => {
await setPage(page);
};Refetch After Mutations
After create, update, or delete operations, call refetchCurrentPage() to reload the current list view.
formDialog.open(DoctorForm, {
title: 'Add New Doctor',
size: 'xl',
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
});Forms
Validation with useFormValidation and useValidationRules
All forms must use useFormValidation for validation state and useValidationRules for reusable rule helpers.
import { useFormValidation } from '~/composables/useFormValidation';
import { required, email, minLength } from '~/composables/useValidationRules';
const { errors, validate } = useFormValidation<Partial<Doctor>>({
first_name: [required('First Name')],
last_name: [required('Last Name')],
doctor_role_id: [required('Specialty')],
});
const handleSubmit = async () => {
if (!validate(form)) return;
await createDoctor(form);
};Available Validation Rules
| Rule | Usage |
|---|---|
required(label) | Field must not be empty |
numeric(label) | Must be a valid number |
greaterThanZero(label) | Must be > 0 |
minValue(label, min) | Minimum numeric value |
maxValue(label, max) | Maximum numeric value |
minLength(label, min) | Minimum string length |
maxLength(label, max) | Maximum string length |
email(label?) | Valid email format |
requiredIf(label, condition) | Conditionally required |
oneOf(label, allowedValues) | Must be one of the listed values |
Bind Errors to Inputs
Pass errors.field_name to the :error prop of UiInput or UiDropdown.
<UiInput
v-model="form.first_name"
:error="errors.first_name"
label="First Name"
placeholder="e.g., Juan"
required="true"
/>Dialogs and Feedback
Dynamic Dialogs with useDialog
Use useDialog().open() to render a component inside the global dialog provider. Pass props and callbacks through the options object.
const formDialog = useDialog();
const handleAddDoctorClick = () => {
formDialog.open(DoctorForm, {
title: 'Add New Doctor',
subtitle: 'Fill in the doctor details below',
size: 'xl',
props: { someData },
onClose: () => refetchCurrentPage(),
onSuccess: () => refetchCurrentPage(),
});
};Confirm Dialogs with useConfirmDialog
Use useConfirmDialog for destructive actions. Pass the onConfirm callback to handle the async operation.
const confirm = useConfirmDialog();
const handleDeleteDoctorClick = async (data: Doctor) => {
confirm.open({
title: `Delete ${data.full_name}`,
message: 'Are you sure you want to delete this doctor?',
onConfirm: async () => {
await deleteDoctorState.deleteDoctor(data.id);
if (deleteDoctorState.success.value) {
snackbar.show('Doctor deleted successfully');
await refetchCurrentPage();
} else if (deleteDoctorState.error.value) {
snackbar.show(deleteDoctorState.error.value, 'error');
}
},
});
};Snackbar Feedback
Always give the user feedback after mutations. Use useSnackBar().show() with an appropriate type.
const snackbar = useSnackBar();
// Success
snackbar.show('Doctor created successfully');
// Error
snackbar.show('Failed to create doctor', 'error');
// Info
snackbar.show('Processing your request...', 'info');Permissions
Template Guards with $can()
Use the $can() plugin to conditionally render UI elements based on user permissions. The plugin is provided via app/plugins/permissions.ts.
<template>
<UiButton
v-if="$can('doctors.create')"
variant="primary"
:left-icon="PlusIcon"
@click="handleAddDoctorClick"
>
Add Doctor
</UiButton>
</template>Composable-Level Permission Checks
For logic outside templates, use useCanAccess() directly.
const { can, canAny, hasRole } = useCanAccess();
if (can('doctors.delete')) {
await doctorService.deleteDoctor(id);
}
if (canAny(['pharmacy.view', 'pharmacy.manage'])) {
// show pharmacy section
}Route Protection
Protect sensitive routes with the auth middleware in definePageMeta. The middleware checks for a valid token and redirects to /login if missing or expired.
definePageMeta({
layout: 'configuration',
middleware: 'auth',
});Navigation
Prefer navigateTo() Over router.push()
In middleware and server-side contexts, use Nuxt's navigateTo() helper. It works correctly in both client and server environments.
// in middleware
export default defineNuxtRouteMiddleware((to) => {
if (!authStore.state.token) {
return navigateTo('/login');
}
});In components, router.push() is acceptable but navigateTo() is preferred for consistency.
Component Design
Keep Components Small
If a component exceeds ~150 lines of template or ~100 lines of script, extract logic into a composable or split into sub-components.
// Instead of one massive DoctorPage.vue:
pages/configuration/doctors/index.vue # page orchestrator
components/configuration/doctor/
DoctorFilters.vue # search / filter UI
DoctorTable.vue # table layout
DoctorTableRow.vue # single row rendering
forms/DoctorForms/DoctorForm.vue # create form
forms/DoctorForms/UpdateDoctorForm.vue # update formLoading States
Always track and display loading states. Use the loading ref from composables and bind it to UiButton's :loading prop or show skeleton loaders.
<UiButton variant="primary" type="submit" :loading="loading">
Add Doctor
</UiButton>