Skip to content

Last updated:

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:

  1. Extracts a human-readable message from the error
  2. Reports the error to Sentry
  3. Re-throws as a plain Error with the extracted message
typescript
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:

  1. FetchError.data.message -- The Laravel API response body, which contains validation errors or business logic messages (e.g., "The email has already been taken.")
  2. FetchError.message -- The HTTP-level error from ofetch (e.g., "[POST] .../doctors: 422 Unprocessable Entity")
  3. '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.

typescript
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: Set loading = false in the finally block so loading state clears even on error
  • Never swallow errors silently: Always set error.value with a message
  • Return all three refs: Components need loading, error, and success to render correctly

Fetch composable pattern

Fetch composables (e.g., useDoctor) populate the Pinia store on success and extract the error message on failure:

typescript
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:

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

typescript
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:

typescript
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:

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

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

typescript
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

RuleUsageError 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:

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

typescript
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:

typescript
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:

typescript
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

typescript
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:

typescript
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:

json
{
  "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 typeService layerComposable layerComponent layer
Validation (422)Extract data.message, Sentry, re-throwSet error.valueDisplay inline in form
Server (500)Extract message, Sentry, re-throwSet error.valueDisplay in snackbar or inline
NetworkFallback message, Sentry, re-throwSet error.valueDisplay in snackbar or inline
Auth (401)Re-throwRedirect to /loginUser sees login page

Error Handling Checklist

When adding new features, verify:

  • [ ] Service methods have try/catch with Sentry.captureException
  • [ ] Service methods extract error messages via getErrorMessage pattern
  • [ ] Composables expose loading, error, success refs
  • [ ] Composables reset state at the start of each operation
  • [ ] Composables use finally to clear loading
  • [ ] Forms call validate(form) before API calls
  • [ ] Forms display error from the composable at the top of the form
  • [ ] Forms pass errors.[field] to each UiInput
  • [ ] Delete actions use useConfirmDialog
  • [ ] Success operations show useSnackBar notification
  • [ ] Failed operations show error via snackbar or inline display
  • [ ] Submit buttons use :loading="loading" prop

CPR - Clinical Patient Records