Skip to content

Last updated:

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.

ComposableResponsibility
useDoctorFetch list, populate store
useCreateDoctorCreate a single doctor
useUpdateDoctorUpdate a single doctor
useDeleteDoctorDelete a single doctor
app/composables/doctor/
  useDoctor.ts           # list / fetch
  useCreateDoctor.ts     # create
  useUpdateDoctor.ts     # update
  useDeleteDoctor.ts     # delete

Composable Return Shape

Every async composable must return loading, error, success, and the action function. This makes consumption in components consistent.

ts
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.

ts
// 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

PatternUse when
Pinia storeMultiple components need the same data (e.g., doctorStore.list used by table + filters + pagination)
ComposableLogic is reused across pages but state is per-instance (e.g., useCreateDoctor)
Module-level refsSingleton state shared app-wide without Pinia overhead (e.g., useDialog, useSnackBar, useConfirmDialog)
Local stateState 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.

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 };
});

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.

ts
// 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.

ts
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.

ts
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.

ts
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.

ts
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

RuleUsage
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.

vue
<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.

ts
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.

ts
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.

ts
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.

vue
<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.

ts
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.

ts
definePageMeta({
  layout: 'configuration',
  middleware: 'auth',
});

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.

ts
// 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 form

Loading 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.

vue
<UiButton variant="primary" type="submit" :loading="loading">
  Add Doctor
</UiButton>

CPR - Clinical Patient Records