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.
<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:
// 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:
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):
<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.
// 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:
// 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:
// 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:
const { refetchCurrentPage } = useListFilters({ ... });
// After creating a new record
await createInsurance(data);
await refetchCurrentPage(); // Forces API call even if params are the sameList Rendering with v-for
Always use a unique key prop when rendering lists. CPR entities have id fields -- use them:
<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-iffor content that is rarely shown (error messages, empty states). It avoids rendering the DOM nodes at all. - Use
v-showfor content that toggles frequently (filter panels, expandable sections). It keeps the DOM nodes and togglesdisplay: none.
<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:
import { useDebounceFn } from '@vueuse/core';
// Or implement manually
const handleScroll = (() => {
let timeout: ReturnType<typeof setTimeout>;
return () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
// Handle scroll
}, 100);
};
})();Related Pages
- Lazy Loading -- Code splitting and dynamic imports
- Caching -- Reducing redundant data fetching
- Bundle Optimization -- Production build optimizations
