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.
// 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.
// 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:
<script setup lang="ts">
const { open } = useDialog();
const openCreateDoctor = () => {
open(DoctorCreateForm, {
title: 'Add New Doctor',
size: 'lg',
onSuccess: () => getDoctors(),
});
};
</script>Key details:
shallowRefis used forcomponentbecause Vue components should not be deeply reactive (it avoids performance overhead and proxy warnings).onCloseandonSuccesscallbacks inDialogOptionslet the caller react to dialog lifecycle events without tight coupling.propspasses arbitrary data to the rendered component viav-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.
// 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:
const { show } = useSnackBar();
const handleSave = async () => {
await createDoctor(form);
show('Doctor created successfully', 'success');
};Key details:
- The
timeoutvariable is module-level but not reactive -- it is a plainletused 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.
// 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:
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 totrue(confirmed) orfalse(cancelled). The caller can useawaitfor clean sequential logic. onConfirmcallback -- if provided, it runs inside the dialog's confirm handler with automaticloadingmanagement. This keeps the dialog open and shows a spinner until the async operation completes.resolvePromiseis a plainlet(not reactive) stored at module level. It is the bridge between the promise returned byopen()and theconfirm()/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 debugging | You want Pinia devtools inspection |
| Single concern (dialog, toast, confirm) | Multiple related fields with business logic |
| No persistence needed | State 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.
// 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.
// 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:
// 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 value | Type | Purpose |
|---|---|---|
loading | Ref<boolean> | true while the API call is in flight |
error | Ref<string> | Error message from the last failed call, empty on success |
success | Ref<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.
// 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:
<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.
// 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:
<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
searchfilter is watched with a 300ms debounce (configurable viadebounceMs). Other filters trigger immediate fetches viawatchImmediateFilter. - 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.
// 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:
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:
| Helper | Description |
|---|---|
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.
// 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.
// 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.
const { print } = useReportPrint();
const handlePrint = () => {
print(reportRef.value, 'Patient Certificate');
};useStatusColor
Maps status strings to color tokens used by UI components.
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)
| Composable | File | Purpose |
|---|---|---|
useDialog | useDialog.ts | Global modal dialog management |
useSnackBar | useSnackBar.ts | Toast notifications |
useConfirmDialog | useConfirmDialog.ts | Confirmation prompts with promise API |
Utility Composables (no shared state)
| Composable | File | Purpose |
|---|---|---|
useCanAccess | useCanAccess.ts | Permission and role checking |
useListFilters | useListFilters.ts | Paginated list filtering with URL sync |
useFormValidation | useFormValidation.ts | Form validation with reactive errors |
useValidationRules | useValidationRules.ts | Reusable validation rule helpers |
useQueryFilters | useQueryFilters.ts | Simple URL query sync for filters |
useQueryParamSync | useQueryParam.ts | Single ref to URL query param binding |
useReportPrint | useReportPrint.ts | Print report in new window |
useStatusColor | useStatusColor.ts | Status string to color mapping |
useApi | useApi.ts | Low-level API client |
useApiFetch | useApiFetch.ts | Authenticated $fetch wrapper |
Domain Composables (per module, in subdirectories)
Each domain directory contains composables for CRUD operations following the use{Action}{Domain} naming pattern:
| Domain | Directory | Composables |
|---|---|---|
| Doctor | doctor/ | useDoctor, useCreateDoctor, useUpdateDoctor, useDeleteDoctor |
| Patient | patient/ | usePatient, useCreatePatient, ... |
| Insurance | insurance/ | useInsurance, useCreateInsurance, ... |
| Medicine | medicine/ | useMedicine, useCreateMedicine, ... |
| Procedure | procedure/ | useProcedure, useCreateProcedure, ... |
| Pharmacy Item | pharmacyItem/ | usePharmacyItem, ... |
| Purchase Order | purchaseOrder/ | usePurchaseOrder, ... |
| Queue | queue/ | useQueue, ... |
| Supplier | supplier/ | useSupplier, ... |
| Surgery Schedule | surgerySchedule/ | useSurgerySchedule, ... |
| Transaction | transaction/ | useTransaction, ... |
| Stock Available | stockAvailable/ | useStockAvailable, ... |
| Stock Movement | stockMovement/ | useStockMovement, ... |
| Delivery | delivery/ | useDelivery, ... |
| Billing | billing/ | useBilling, ... |
| Service | service/ | useService, ... |
| Profile | profile/ | useProfile, ... |
| Surgery Location | surgeryLocation/ | useSurgeryLocation, ... |
| Auth | auth/ | useAuth, ... |
| Bill Item Source | billItemSource/ | useBillItemSource, ... |
| Payment Method | paymentMethod/ | usePaymentMethod, ... |
| Purchase Order Status | purchaseOrderStatus/ | usePurchaseOrderStatus, ... |
| System | system/ | useSystem, ... |
Related Pages
- State Management Overview -- decision tree for choosing the right state layer
- Pinia Store Patterns -- store conventions and categories
- Coding Standards -- error handling and reactivity rules
- API Integration -- service layer that composables call
