Skip to content

Last updated:

Performance Optimization

CPR runs as a Single-Page Application (SPA with ssr: false). All rendering happens in the browser, so optimizing client-side performance is essential for the fast, responsive experience that clinical staff depend on during patient consultations.

SPA-Specific Considerations

Because there is no server-side rendering, the initial load involves downloading the JavaScript bundle, hydrating Vue, and making API calls. Optimize for:

  • Minimal initial bundle size -- Only load what the current route needs.
  • Fast API responses -- Avoid waterfalls; fetch data as early as possible.
  • Smooth interactions -- Keep the main thread free during data-heavy operations like queue lists and patient search.

Component Render Optimization

Use computed Instead of Methods for Derived State

computed properties are cached and only re-evaluate when their reactive dependencies change. Methods run on every render.

vue
<script setup lang="ts">
// Good -- cached, re-evaluates only when patient.categories changes
const categoryNames = computed(() =>
  patient.value.categories.map((c) => c.name).join(', ')
);

// Avoid -- runs on every render cycle
function getCategoryNames() {
  return patient.value.categories.map((c) => c.name).join(', ');
}
</script>

<template>
  <!-- Good -->
  <span>{{ categoryNames }}</span>

  <!-- Avoid -->
  <span>{{ getCategoryNames() }}</span>
</template>

Avoid Unnecessary Reactivity

Not everything needs to be reactive. Use plain variables for values that do not change after initialization:

ts
// Good -- static config, no reactivity needed
const STATUS_COLOR_MAP: Record<string, StatusColor> = {
  DISCHARGED: 'gray',
  PENDING: 'yellow',
  ACTIVE: 'green',
};

// Avoid -- making static data reactive adds overhead
const STATUS_COLOR_MAP = reactive({
  DISCHARGED: 'gray',
  PENDING: 'yellow',
  ACTIVE: 'green',
});

Use shallowRef for Large Objects

When storing large API responses (like paginated patient lists), use shallowRef if you replace the entire object rather than mutating nested properties:

ts
import { shallowRef } from 'vue';

// Good for large lists that are replaced wholesale
const patients = shallowRef<Patient[]>([]);

// When fetching, replace the entire array
patients.value = response.data;

Use v-once for Static Content

For content that never changes after initial render (headers, labels, static UI):

vue
<template>
  <h1 v-once>CPR - Computerized Patient Record</h1>
</template>

Debounced Search with useListFilters

CPR's useListFilters composable includes built-in search debouncing to prevent API calls on every keystroke. This is critical for pages like patient search and transaction lists that can have thousands of records.

ts
// The composable debounces search input by default (300ms)
const { filters, filter, watchImmediateFilter } = useListFilters({
  initialFilters: {
    search: undefined,
    status: undefined,
  },
  searchKey: 'search',      // This key gets debounced
  debounceMs: 300,           // Wait 300ms after the user stops typing
  onFilter: async (params) => {
    await transactionService.getTransactions(params);
  },
});

// Non-search filters (like status dropdowns) trigger immediately
watchImmediateFilter(['status']);

How the debounce works internally:

ts
// From useListFilters.ts -- search field is watched with a timeout
watch(
  () => filters.value[searchKey],
  (value) => {
    clearTimeout(searchTimeout);
    searchTimeout = setTimeout(async () => {
      filters.value[searchKey] = value;
      resetToFirstPage();
      await updateQuery();
      await filter();
    }, debounceMs);
  }
);

Avoiding Redundant API Calls

useListFilters tracks the last fetch parameters to prevent duplicate requests:

ts
// From useListFilters.ts
let lastFetchParams = '';

const filter = async () => {
  const currentParams = JSON.stringify(buildParams());
  // Skip if params haven't changed
  if (currentParams === lastFetchParams) return;
  lastFetchParams = currentParams;

  await options.onFilter(buildParams());
};

This means if a user clicks a filter tab that is already active, no API call is made. After a create/update/delete operation, use refetchCurrentPage() which resets lastFetchParams to force a fresh fetch:

ts
const { refetchCurrentPage } = useListFilters({ ... });

// After creating a new record
await createInsurance(data);
await refetchCurrentPage(); // Forces API call even if params are the same

List Rendering with v-for

Always use a unique key prop when rendering lists. CPR entities have id fields -- use them:

vue
<template>
  <!-- Good -- unique, stable key -->
  <tr v-for="patient in patients" :key="patient.id">
    <td>{{ patient.last_name }}, {{ patient.first_name }}</td>
  </tr>

  <!-- Avoid -- index-based keys cause unnecessary re-renders -->
  <tr v-for="(patient, index) in patients" :key="index">
    <td>{{ patient.last_name }}, {{ patient.first_name }}</td>
  </tr>
</template>

Conditional Rendering: v-if vs v-show

  • Use v-if for content that is rarely shown (error messages, empty states). It avoids rendering the DOM nodes at all.
  • Use v-show for content that toggles frequently (filter panels, expandable sections). It keeps the DOM nodes and toggles display: none.
vue
<template>
  <!-- v-if: good for error messages that rarely appear -->
  <div v-if="error" class="text-red-500">{{ error }}</div>

  <!-- v-show: good for filter panel that toggles often -->
  <div v-show="showFilters" class="filter-panel">
    <!-- filter inputs -->
  </div>
</template>

Event Handler Optimization

For components that fire events rapidly (scroll, resize, mousemove), debounce or throttle the handler:

ts
import { useDebounceFn } from '@vueuse/core';

// Or implement manually
const handleScroll = (() => {
  let timeout: ReturnType<typeof setTimeout>;
  return () => {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      // Handle scroll
    }, 100);
  };
})();

CPR - Clinical Patient Records