Error Handling Standards
CPR handles errors at three layers: services, composables, and components. Each layer has a specific responsibility, and errors flow upward from the API through to the user interface.
Error Flow
┌──────────────────────────────────────────────────────────────┐
│ Component Layer │
│ Display errors: inline (error ref), snackbar, confirm │
│ Show loading states, disable buttons during operations │
├──────────────────────────────────────────────────────────────┤
│ Composable Layer │
│ Catch errors from service, set error.value and │
│ success.value refs, always set loading to false in finally │
├──────────────────────────────────────────────────────────────┤
│ Service Layer │
│ Catch FetchError, extract message from │
│ FetchError.data.message, capture to Sentry, re-throw │
├──────────────────────────────────────────────────────────────┤
│ useApiFetch / $fetch │
│ Throws FetchError on non-2xx responses │
└──────────────────────────────────────────────────────────────┘Service Layer Error Handling
Location: app/sevices/*.service.ts
Every service method wraps its API call in a try/catch block. The catch block:
- Extracts a human-readable message from the error
- Reports the error to Sentry
- Re-throws as a plain
Errorwith the extracted message
class DoctorService {
private getErrorMessage(err: unknown): string {
const fetchError = err as FetchError<unknown>;
return (
(fetchError.data as { message?: string })?.message ??
fetchError.message ??
'Something went wrong'
);
}
async createDoctor(params: Partial<Doctor>): Promise<Doctor> {
try {
const res: ApiResponse<Doctor> = await useApiFetch('/doctors', {
method: 'POST',
body: params,
});
return res.data;
} catch (err: unknown) {
const message = this.getErrorMessage(err);
Sentry.captureException(err);
throw new Error(message);
}
}
}Error message extraction priority
The getErrorMessage method checks for error information in this order:
FetchError.data.message-- The Laravel API response body, which contains validation errors or business logic messages (e.g.,"The email has already been taken.")FetchError.message-- The HTTP-level error fromofetch(e.g.,"[POST] .../doctors: 422 Unprocessable Entity")'Something went wrong'-- Fallback when no error information is available
Why re-throw as Error
Services re-throw as new Error(message) (not the original FetchError) to:
- Provide a clean, user-friendly message to the composable layer
- Prevent leaking raw HTTP error details to the UI
- Ensure Sentry captures the original error with full context while the composable gets a simplified version
Composable Layer Error Handling
Location: app/composables/[entity]/*.ts
Composables catch errors from services and store the message in a reactive error ref. They always manage loading and success state.
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 };
};Composable error handling rules
- Always reset state before the operation: Set
loading = true,success = false,error = '' - Always use
finally: Setloading = falsein thefinallyblock so loading state clears even on error - Never swallow errors silently: Always set
error.valuewith a message - Return all three refs: Components need
loading,error, andsuccessto render correctly
Fetch composable pattern
Fetch composables (e.g., useDoctor) populate the Pinia store on success and extract the error message on failure:
export const useDoctor = () => {
const doctorStore = useDoctorStore();
const success = ref(false);
const loading = ref(false);
const error = ref('');
const getDoctors = async (search?: string, filters?: { status?: string }, page?: number) => {
loading.value = true;
try {
const paginatedDoctors = await doctorService.getDoctors({ search, ...filters, page });
const { data, ...pagination } = paginatedDoctors;
doctorStore.list = data;
doctorStore.pagination = pagination;
error.value = '';
success.value = true;
} catch (err) {
const fetchError = err as { data?: { message?: string }; message?: string };
error.value = fetchError.data?.message ?? fetchError.message ?? 'Something went wrong';
success.value = false;
} finally {
loading.value = false;
}
};
return { loading, error, success, getDoctors };
};Component Layer Error Handling
Components consume the loading, error, and success refs from composables and display them to the user.
Inline error display
Show errors directly in the page or form when the user needs to see them in context:
<template>
<!-- Page-level error (e.g., list fetch failed) -->
<div
v-if="error.value"
class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm flex items-center gap-2"
>
<div class="w-1.5 h-1.5 rounded-full bg-red-500" />
{{ error.value }}
</div>
<!-- Form-level error (e.g., create/update failed) -->
<div
v-if="error"
class="col-span-full p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm"
>
{{ error }}
</div>
</template>Snackbar notifications
Use useSnackBar for transient success/error messages that do not need to persist on screen:
const snackbar = useSnackBar();
// Success notification
snackbar.show('Doctor created successfully'); // defaults to 'success' type
// Error notification
snackbar.show('Failed to delete doctor', 'error');
// Info notification
snackbar.show('Processing your request...', 'info');The useSnackBar composable API:
const { message, type, visible, show, close } = useSnackBar();
// show(msg, msgType, duration)
// msg: string
// msgType: 'success' | 'error' | 'info' (default: 'success')
// duration: number in ms (default: 3000)The UiSnackBar component is rendered globally by the layout and reads from the shared snackbar state.
Loading states
Always use the loading ref to show loading indicators and disable interactive elements:
<template>
<!-- Disable submit button and show spinner during API call -->
<UiButton
variant="primary"
type="submit"
:loading="loading"
>
Add Doctor
</UiButton>
</template>401 Unauthorized Handling
When the API returns a 401 response, the user's session has expired or their token is invalid. The useApi composable handles this automatically:
const handleError = (err: unknown, fallbackMessage: string) => {
const fetchErr = err as { status?: number; statusCode?: number; message?: string };
if (fetchErr.status === 401 || fetchErr.statusCode === 401) {
auth.reset(); // Clear auth state
sessionStorage.removeItem('auth'); // Clear stored session
navigateTo('/login'); // Redirect to login
}
error.value = fetchErr.message || fallbackMessage;
};When using the service + composable pattern (instead of useApi), the auth middleware (app/middleware/auth.ts) protects routes and redirects unauthenticated users to /login.
Form Validation
useFormValidation
Location: app/composables/useFormValidation.ts
Client-side form validation before submitting to the API. Define validation rules per field and call validate(form) before the API call.
import { useFormValidation } from '~/composables/useFormValidation';
import { required, email, minLength } from '~/composables/useValidationRules';
const form = reactive({
first_name: '',
last_name: '',
email: '',
password: '',
});
const { errors, validate, clearErrors, setError, clearError } = useFormValidation({
first_name: [required('First Name')],
last_name: [required('Last Name')],
email: [required('Email'), email()],
password: [required('Password'), minLength('Password', 8)],
});
const handleSubmit = async () => {
if (!validate(form)) return; // Stops if validation fails
await createUser(form);
};Available validation rules
Location: app/composables/useValidationRules.ts
| Rule | Usage | Error message |
|---|---|---|
required(label) | required('First Name') | "First Name is required" |
numeric(label) | numeric('Amount') | "Amount must be a number" |
greaterThanZero(label) | greaterThanZero('Quantity') | "Quantity must be greater than 0" |
minValue(label, min) | minValue('Age', 18) | "Age must be at least 18" |
maxValue(label, max) | maxValue('Quantity', 100) | "Quantity must not be greater than 100" |
minLength(label, min) | minLength('Password', 8) | "Password must be at least 8 characters" |
maxLength(label, max) | maxLength('Name', 255) | "Name must not be more than 255 characters" |
email(label?) | email() or email('Work Email') | "Email must be a valid email address" |
requiredIf(label, condition) | requiredIf('Note', isUrgent) | "Note is required" (when condition is true) |
oneOf(label, values) | oneOf('Status', ['active', 'inactive']) | "Status is invalid" |
Inline error display on form fields
The errors reactive object maps field names to error messages. Pass errors to UiInput components:
<UiInput
v-model="form.first_name"
:error="errors.first_name"
label="First Name"
required="true"
/>When validation fails, the UiInput component displays the error message below the input field.
Form-level validation rules
For cross-field validation (e.g., confirm password), use a form-level rule that receives the entire form object:
const { errors, validate } = useFormValidation({
password: [required('Password'), minLength('Password', 8)],
confirm_password: [
required('Confirm Password'),
(form) =>
form.password !== form.confirm_password
? 'Passwords do not match'
: null,
],
});Setting errors manually
Use setError and clearError for API-returned validation errors that are not caught by client-side rules:
const { errors, setError, clearError } = useFormValidation({ ... });
// Set an error from API response
setError('email', 'The email has already been taken.');
// Clear a specific field error
clearError('email');Confirm Dialog for Destructive Actions
Location: app/composables/useConfirmDialog.ts
Always use useConfirmDialog before delete operations or other irreversible actions:
const confirm = useConfirmDialog();
const snackbar = useSnackBar();
const deleteDoctorState = useDeleteDoctor();
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');
}
},
});
};useConfirmDialog API
const {
visible, // ref<boolean> - whether the dialog is shown
title, // ref<string> - dialog title
message, // ref<string> - dialog body text
loading, // ref<boolean> - true while onConfirm is executing
open, // (opts) => Promise<boolean> - show the dialog
confirm, // () => void - execute onConfirm and close
cancel, // () => void - close without executing
setLoading, // (value) => void - manually control loading
} = useConfirmDialog();The open() method accepts:
interface ConfirmDialogOptions {
title?: string; // Dialog title (default: 'Confirm')
message?: string; // Dialog message (default: 'Are you sure?')
onConfirm?: () => Promise<void> | void; // Action to execute on confirm
}The UiConfirmDialogProvider component is mounted globally in the root layout and renders the dialog when visible is true. The loading ref automatically shows a spinner on the confirm button while onConfirm runs.
Network Errors vs Validation Errors
The CPR API returns different error shapes depending on the failure type:
Validation errors (422)
Returned when form data is invalid. The response body contains a message field with a human-readable description:
{
"message": "The email has already been taken.",
"errors": {
"email": ["The email has already been taken."]
}
}The service layer extracts FetchError.data.message, which gives the top-level message string. This is suitable for displaying as a general form error.
Server errors (500)
Returned on unexpected backend failures. The FetchError.data.message may contain a generic message or nothing useful. The service falls back to FetchError.message (the HTTP status text) or 'Something went wrong'.
Network errors
When the API is unreachable (no internet, server down), ofetch throws with no data property. The service falls back to FetchError.message (e.g., "Failed to fetch") or the default 'Something went wrong'.
How each layer handles the differences
| Error type | Service layer | Composable layer | Component layer |
|---|---|---|---|
| Validation (422) | Extract data.message, Sentry, re-throw | Set error.value | Display inline in form |
| Server (500) | Extract message, Sentry, re-throw | Set error.value | Display in snackbar or inline |
| Network | Fallback message, Sentry, re-throw | Set error.value | Display in snackbar or inline |
| Auth (401) | Re-throw | Redirect to /login | User sees login page |
Error Handling Checklist
When adding new features, verify:
- [ ] Service methods have
try/catchwithSentry.captureException - [ ] Service methods extract error messages via
getErrorMessagepattern - [ ] Composables expose
loading,error,successrefs - [ ] Composables reset state at the start of each operation
- [ ] Composables use
finallyto clearloading - [ ] Forms call
validate(form)before API calls - [ ] Forms display
errorfrom the composable at the top of the form - [ ] Forms pass
errors.[field]to eachUiInput - [ ] Delete actions use
useConfirmDialog - [ ] Success operations show
useSnackBarnotification - [ ] Failed operations show error via snackbar or inline display
- [ ] Submit buttons use
:loading="loading"prop
