Skip to content

Last updated:

Composable-Based State

Composables in app/composables/ fall into three categories: shared-state composables that use module-level refs for app-wide singleton state, domain composables that orchestrate API calls and store updates, and utility composables that provide reusable logic without global state. This page covers all three.

Shared-State Composables (Module-Level Refs)

The Pattern

When a ref is declared outside the exported function, at the top level of the module, it becomes singleton state. Every component that calls the composable shares the same reactive value. This is because ES modules are evaluated once and cached.

ts
// Module-level -- shared across all callers
const visible = ref(false);

// Function-level -- each caller gets its own instance
export function useExample() {
  const localLoading = ref(false); // NOT shared

  return { visible, localLoading };
}

This pattern is used for app-wide UI state that does not belong in a Pinia store: dialogs, toast notifications, and confirmation prompts.

useDialog

Manages a single global modal dialog. Any component can open a dialog by passing a component and options; the UiDialog shell component in the layout reads the shared state to render it.

ts
// app/composables/useDialog.ts
import { ref, shallowRef, type Component } from 'vue';

export interface DialogOptions {
  title?: string;
  subtitle?: string;
  plain?: boolean;
  size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
       | '5xl' | '6xl' | '7xl' | 'full';
  position?: 'center' | 'top';
  props?: Record<string, unknown>;
  onClose?: () => void;
  onSuccess?: () => void;
}

const visible = ref(false);
const component = shallowRef<Component | null>(null);
const options = ref<DialogOptions>({});

export function useDialog() {
  const open = (comp: Component, opts: DialogOptions = {}) => {
    component.value = comp;
    options.value = opts;
    visible.value = true;
  };

  const close = () => {
    visible.value = false;
    options.value.onClose?.();
    component.value = null;
    options.value = {};
  };

  return { visible, component, options, open, close };
}

Usage in a component:

vue
<script setup lang="ts">
const { open } = useDialog();

const openCreateDoctor = () => {
  open(DoctorCreateForm, {
    title: 'Add New Doctor',
    size: 'lg',
    onSuccess: () => getDoctors(),
  });
};
</script>

Key details:

  • shallowRef is used for component because Vue components should not be deeply reactive (it avoids performance overhead and proxy warnings).
  • onClose and onSuccess callbacks in DialogOptions let the caller react to dialog lifecycle events without tight coupling.
  • props passes arbitrary data to the rendered component via v-bind.
  • Only one dialog can be open at a time. Opening a second dialog replaces the first.

useSnackBar

Displays a temporary toast notification. Auto-dismisses after a configurable duration.

ts
// app/composables/useSnackBar.ts
import { ref } from 'vue';

export type SnackBarType = 'success' | 'error' | 'info';

const message = ref('');
const type = ref<SnackBarType>('success');
const visible = ref(false);
let timeout: ReturnType<typeof setTimeout> | null = null;

export function useSnackBar() {
  const show = (
    msg: string,
    msgType: SnackBarType = 'success',
    duration = 3000
  ) => {
    message.value = msg;
    type.value = msgType;
    visible.value = true;
    if (timeout) clearTimeout(timeout);
    timeout = setTimeout(() => close(), duration);
  };

  const close = () => {
    visible.value = false;
  };

  return { message, type, visible, show, close };
}

Usage after a successful operation:

ts
const { show } = useSnackBar();

const handleSave = async () => {
  await createDoctor(form);
  show('Doctor created successfully', 'success');
};

Key details:

  • The timeout variable is module-level but not reactive -- it is a plain let used only for clearing the previous timer.
  • Calling show() while a previous snackbar is visible resets the timer and replaces the message.

useConfirmDialog

Displays a confirmation prompt and returns a Promise<boolean> so the caller can await the user's decision.

ts
// app/composables/useConfirmDialog.ts
import { ref } from 'vue';

export interface ConfirmDialogOptions {
  title?: string;
  message?: string;
  onConfirm?: () => Promise<void> | void;
}

const visible = ref(false);
const title = ref('');
const message = ref('');
const loading = ref(false);

let resolvePromise: ((value: boolean) => void) | null = null;
let onConfirmCallback: (() => Promise<void> | void) | undefined;

export function useConfirmDialog() {
  const open = (opts: ConfirmDialogOptions = {}): Promise<boolean> => {
    title.value = opts.title ?? 'Confirm';
    message.value = opts.message ?? 'Are you sure?';
    loading.value = false;
    visible.value = true;
    onConfirmCallback = opts.onConfirm;

    return new Promise((resolve) => {
      resolvePromise = resolve;
    });
  };

  const confirm = async () => {
    if (onConfirmCallback) {
      loading.value = true;
      try {
        await onConfirmCallback();
      } finally {
        loading.value = false;
      }
    }
    resolvePromise?.(true);
    resolvePromise = null;
    visible.value = false;
    onConfirmCallback = undefined;
  };

  const cancel = () => {
    resolvePromise?.(false);
    resolvePromise = null;
    visible.value = false;
    onConfirmCallback = undefined;
  };

  const setLoading = (value: boolean) => {
    loading.value = value;
  };

  return { visible, title, message, loading, open, confirm, cancel, setLoading };
}

Usage for a delete action:

ts
const { open } = useConfirmDialog();
const { show } = useSnackBar();

const handleDelete = async (doctor: Doctor) => {
  const confirmed = await open({
    title: 'Delete Doctor',
    message: `Are you sure you want to delete ${doctor.first_name}?`,
    onConfirm: () => deleteDoctor(doctor.id),
  });

  if (confirmed) {
    show('Doctor deleted successfully');
    await getDoctors(); // refresh the list
  }
};

Key details:

  • Promise-based API -- open() returns a promise that resolves to true (confirmed) or false (cancelled). The caller can use await for clean sequential logic.
  • onConfirm callback -- if provided, it runs inside the dialog's confirm handler with automatic loading management. This keeps the dialog open and shows a spinner until the async operation completes.
  • resolvePromise is a plain let (not reactive) stored at module level. It is the bridge between the promise returned by open() and the confirm()/cancel() functions called by the dialog's buttons.

When to Use Module-Level State vs. Pinia

Use module-level composable when...Use Pinia store when...
State is UI-only (no API data)State holds API-fetched domain data
No need for devtools time-travel debuggingYou want Pinia devtools inspection
Single concern (dialog, toast, confirm)Multiple related fields with business logic
No persistence neededState needs sessionStorage/localStorage

Domain Composables

Domain composables live in subdirectories of app/composables/ named after their domain (e.g., doctor/, patient/, queue/). Each composable handles one operation and follows a consistent pattern.

Directory Structure

app/composables/
├── doctor/
│   ├── useDoctor.ts          # List/fetch
│   ├── useCreateDoctor.ts    # Create
│   ├── useUpdateDoctor.ts    # Update
│   └── useDeleteDoctor.ts    # Delete
├── patient/
│   ├── usePatient.ts
│   ├── useCreatePatient.ts
│   └── ...
├── insurance/
├── medicine/
├── pharmacyItem/
├── procedure/
├── purchaseOrder/
├── queue/
├── supplier/
├── surgerySchedule/
├── transaction/
└── ...

Each domain gets one composable per CRUD operation. This keeps composables small, testable, and independently importable.

List/Fetch Composable (useDoctor)

Fetches a paginated list and writes results to the domain's Pinia store.

ts
// app/composables/doctor/useDoctor.ts
import type { FetchError } from 'ofetch';
import doctorService from '~/sevices/doctor.service';
import { useDoctorStore } from '~/stores/doctor.store';
import type { DoctorFilters } from '~/types/doctor.type';

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; perPage?: number },
    page?: number
  ) => {
    loading.value = true;

    try {
      const doctorFilters: DoctorFilters = {};
      if (search) doctorFilters.search = search;
      if (filters?.status) doctorFilters.status = filters.status;
      if (filters?.perPage) doctorFilters.perPage = filters.perPage;
      if (page) doctorFilters.page = page;

      const paginatedDoctors = await doctorService.getDoctors(doctorFilters);
      const { data, ...pagination } = paginatedDoctors;

      doctorStore.list = data;
      doctorStore.pagination = pagination;

      error.value = '';
      success.value = true;
    } catch (err) {
      const fetchError = err as FetchError<{ message?: string }>;
      const message =
        fetchError.data?.message ??
        fetchError.message ??
        'Something went wrong';

      error.value = message;
      success.value = false;
    } finally {
      loading.value = false;
    }
  };

  return { loading, error, success, getDoctors };
};

Create Composable (useCreateDoctor)

Handles a single create operation. Does not write to the store -- the caller is expected to refetch the list after success.

ts
// app/composables/doctor/useCreateDoctor.ts
import doctorService from '~/sevices/doctor.service';
import type { Doctor } from '~/types/doctor.type';

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

Update and Delete Composables

Follow the same structure as create. The only difference is the function signature:

ts
// useUpdateDoctor
const updateDoctor = async (id: number, params: Partial<Doctor>) => { ... };

// useDeleteDoctor
const deleteDoctor = async (id: number) => { ... };

Standard Contract

Every domain composable returns the same three reactive refs plus one or more async functions:

Return valueTypePurpose
loadingRef<boolean>true while the API call is in flight
errorRef<string>Error message from the last failed call, empty on success
successRef<boolean>true after a successful call
{action}(...) => Promise<void>The async function that performs the operation

This contract is consistent across all domain composables in the project, making them predictable and easy to use in components.


Utility Composables

Utility composables provide reusable logic that is not tied to a specific domain. They do not hold module-level shared state -- each caller gets its own instance.

useCanAccess

Permission checking composable. Reads the authenticated user's permissions and roles arrays from useAuthStore and provides guard functions.

ts
// app/composables/useCanAccess.ts
export function useCanAccess() {
  const authStore = useAuthStore();

  const can = (permission: string): boolean => {
    const user = authStore.state.user;
    if (!user) return false;
    return (user.permissions || []).includes(permission);
  };

  const cannot = (permission: string): boolean => !can(permission);

  const canAny = (permissions: string[]): boolean => {
    const user = authStore.state.user;
    if (!user) return false;
    return permissions.some((p) => (user.permissions || []).includes(p));
  };

  const canAll = (permissions: string[]): boolean => {
    const user = authStore.state.user;
    if (!user) return false;
    return permissions.every((p) => (user.permissions || []).includes(p));
  };

  const hasRole = (role: string): boolean => {
    const user = authStore.state.user;
    if (!user) return false;
    return (user.roles || []).includes(role);
  };

  const hasAnyRole = (roles: string[]): boolean => {
    const user = authStore.state.user;
    if (!user) return false;
    return roles.some((r) => (user.roles || []).includes(r));
  };

  return { can, cannot, canAny, canAll, hasRole, hasAnyRole };
}

Usage in a component:

vue
<script setup lang="ts">
const { can, hasRole } = useCanAccess();
</script>

<template>
  <UiButton v-if="can('doctor.create')" @click="openCreateDoctor">
    Add Doctor
  </UiButton>

  <AdminPanel v-if="hasRole('admin')" />
</template>

The useCanAccess composable is also available globally via the $can() helper registered as a Nuxt plugin.

useListFilters

A generic composable for list pages that combines search debounce, filter watching, pagination, and URL query synchronization into one reusable unit.

ts
// app/composables/useListFilters.ts
export const useListFilters = <T extends Record<string, QueryValue>>(
  options: UseListFiltersOptions<T>
) => {
  // ...
  return {
    filters,              // Ref<T> -- current filter values
    currentPage,          // Ref<number> -- current page
    filter,               // () => Promise<void> -- execute fetch
    setPage,              // (page: number) => Promise<void>
    watchImmediateFilter,  // (keys: K[]) => void -- watch non-search filters
    refetchCurrentPage,    // (page?: number) => Promise<void>
    syncFromRoute,         // () => void -- restore from URL
    updateQuery,           // () => Promise<void> -- sync URL
  };
};

Usage on a list page:

vue
<script setup lang="ts">
const { loading, getDoctors } = useDoctor();
const doctorStore = useDoctorStore();

const {
  filters,
  currentPage,
  setPage,
  filter,
  watchImmediateFilter,
  refetchCurrentPage,
} = useListFilters({
  initialFilters: {
    search: '',
    status: undefined,
  },
  onFilter: async (params) => {
    await getDoctors(params.search, { status: params.status }, params.page);
  },
});

// Watch status filter -- triggers immediate fetch (no debounce)
watchImmediateFilter(['status']);

// Initial fetch
onMounted(() => filter());
</script>

<template>
  <UiInput v-model="filters.search" placeholder="Search doctors..." />

  <select v-model="filters.status">
    <option value="">All</option>
    <option value="active">Active</option>
    <option value="inactive">Inactive</option>
  </select>

  <DoctorTable :doctors="doctorStore.list" />

  <UiPagination
    :meta="doctorStore.pagination"
    @page-change="setPage"
  />
</template>

Key features:

  • Search debounce -- the search filter is watched with a 300ms debounce (configurable via debounceMs). Other filters trigger immediate fetches via watchImmediateFilter.
  • URL sync -- filter values and the current page are reflected in the URL query string (?search=abc&status=active&page=2). This means bookmarking and browser back/forward work correctly.
  • Deduplication -- filter() tracks the last fetch params and skips redundant requests.
  • refetchCurrentPage() -- forces a re-fetch even if params have not changed. Used after create/update/delete operations to refresh the list.

useFormValidation

Generic form validation composable. Accepts a rules map and returns an errors reactive object and a validate() function.

ts
// app/composables/useFormValidation.ts
export const useFormValidation = <T extends Record<string, unknown>>(
  rules: ValidationRules<T>
) => {
  const errors = reactive<Record<string, string>>({});

  const validate = (form: T) => {
    clearErrors();
    // Run each rule, set errors[field] on first failure
    return !Object.values(errors).some(Boolean);
  };

  const setError = (field: string, message: string) => { ... };
  const clearError = (field: string) => { ... };
  const clearErrors = () => { ... };

  return { errors, validate, clearErrors, setError, clearError };
};

Used together with the useValidationRules helpers:

ts
import { required, email, minLength } from '~/composables/useValidationRules';

const { errors, validate } = useFormValidation<DoctorForm>({
  first_name: [required('First name')],
  last_name: [required('Last name')],
  email: [required('Email'), email()],
  password: [required('Password'), minLength('Password', 8)],
});

const handleSubmit = () => {
  if (!validate(form)) return; // errors is now populated
  await createDoctor(form);
};

Available validation rule helpers in useValidationRules.ts:

HelperDescription
required(label)Field must not be null, undefined, or empty string
numeric(label)Value must be a valid number
greaterThanZero(label)Value must be > 0
minValue(label, min)Value must be >= min
maxValue(label, max)Value must be <= max
minLength(label, min)String length must be >= min
maxLength(label, max)String length must be <= max
email(label?)Must match email pattern
requiredIf(label, condition)Required only when condition is true
oneOf(label, allowedValues)Value must be in the allowed list

useQueryFilters

A simpler alternative to useListFilters for pages that need URL query sync without debounce or pagination logic.

ts
// app/composables/useQueryFilters.ts
export function useQueryFilters<T extends Record<string, unknown>>() {
  const filters = ref<T>({ ...(route.query as Partial<T>) } as T);

  const setFilters = async <K extends keyof T>(key: K, value: T[K]) => {
    filters.value = { ...filters.value, [key]: value };
    await router.replace({ query: filters.value as Record<string, string> });
  };

  const resetFilters = async () => {
    filters.value = {} as T;
    await router.replace({ query: {} });
  };

  return { filters, setFilters, resetFilters };
}

useQueryParamSync

Binds a single ref to a single URL query parameter with two-way sync. Supports transforms for string, enum, and number types, plus optional debounce.

ts
// Sync a search ref to ?search= in the URL with 300ms debounce
const search = ref('');
useQueryParamSync(search, 'search', {
  debounce: 300,
  transform: stringQueryTransform(),
});

// Sync a status ref to ?status= (remove "all" from URL)
const status = ref('all');
useQueryParamSync(status, 'status', {
  transform: enumQueryTransform(['all', '']),
});

// Sync a page ref to ?page= (remove page 1 from URL)
const page = ref(1);
useQueryParamSync(page, 'page', {
  transform: numberQueryTransform([1]),
});

useReportPrint

Opens a new browser window with cloned content and triggers window.print(). Collects all current page styles so the print output matches the screen.

ts
const { print } = useReportPrint();

const handlePrint = () => {
  print(reportRef.value, 'Patient Certificate');
};

useStatusColor

Maps status strings to color tokens used by UI components.

ts
const { getStatusColor } = useStatus();

const color = getStatusColor('ACTIVE');   // 'green'
const color = getStatusColor('PENDING');  // 'yellow'
const color = getStatusColor('DISCHARGED'); // 'gray'

Complete Composable Index

Shared-State Composables (module-level refs)

ComposableFilePurpose
useDialoguseDialog.tsGlobal modal dialog management
useSnackBaruseSnackBar.tsToast notifications
useConfirmDialoguseConfirmDialog.tsConfirmation prompts with promise API

Utility Composables (no shared state)

ComposableFilePurpose
useCanAccessuseCanAccess.tsPermission and role checking
useListFiltersuseListFilters.tsPaginated list filtering with URL sync
useFormValidationuseFormValidation.tsForm validation with reactive errors
useValidationRulesuseValidationRules.tsReusable validation rule helpers
useQueryFiltersuseQueryFilters.tsSimple URL query sync for filters
useQueryParamSyncuseQueryParam.tsSingle ref to URL query param binding
useReportPrintuseReportPrint.tsPrint report in new window
useStatusColoruseStatusColor.tsStatus string to color mapping
useApiuseApi.tsLow-level API client
useApiFetchuseApiFetch.tsAuthenticated $fetch wrapper

Domain Composables (per module, in subdirectories)

Each domain directory contains composables for CRUD operations following the use{Action}{Domain} naming pattern:

DomainDirectoryComposables
Doctordoctor/useDoctor, useCreateDoctor, useUpdateDoctor, useDeleteDoctor
Patientpatient/usePatient, useCreatePatient, ...
Insuranceinsurance/useInsurance, useCreateInsurance, ...
Medicinemedicine/useMedicine, useCreateMedicine, ...
Procedureprocedure/useProcedure, useCreateProcedure, ...
Pharmacy ItempharmacyItem/usePharmacyItem, ...
Purchase OrderpurchaseOrder/usePurchaseOrder, ...
Queuequeue/useQueue, ...
Suppliersupplier/useSupplier, ...
Surgery SchedulesurgerySchedule/useSurgerySchedule, ...
Transactiontransaction/useTransaction, ...
Stock AvailablestockAvailable/useStockAvailable, ...
Stock MovementstockMovement/useStockMovement, ...
Deliverydelivery/useDelivery, ...
Billingbilling/useBilling, ...
Serviceservice/useService, ...
Profileprofile/useProfile, ...
Surgery LocationsurgeryLocation/useSurgeryLocation, ...
Authauth/useAuth, ...
Bill Item SourcebillItemSource/useBillItemSource, ...
Payment MethodpaymentMethod/usePaymentMethod, ...
Purchase Order StatuspurchaseOrderStatus/usePurchaseOrderStatus, ...
Systemsystem/useSystem, ...

CPR - Clinical Patient Records