Skip to content

Last updated:

State Management Overview

CPR uses three layers of state management, each suited to a different scope and lifetime. Choosing the right layer keeps components simple and avoids unnecessary global state.

The Three Layers

LayerToolScopeLifetimeExample
Global storePinia defineStoreEntire appUntil page refreshusePatientStore (patient list + pagination)
Shared singletonModule-level ref in a composableEntire appUntil page refreshuseDialog (one dialog visible at a time)
Local componentref / reactive inside <script setup>Single componentUntil component unmountsA form's reactive({...}) fields

Decision Tree

Use this flowchart when adding new state to decide where it belongs.

Does more than one component need this state?
├── NO  --> Local component state (ref / reactive in <script setup>)
└── YES
    ├── Is it domain data fetched from the API (lists, pagination, single records)?
    │   └── YES --> Pinia store (e.g., useDoctorStore)
    ├── Is it UI-only state shared across the app (dialogs, snackbars, confirm prompts)?
    │   └── YES --> Module-level shared composable (e.g., useDialog)
    ├── Is it auth / session data that must survive navigation?
    │   └── YES --> Pinia store with hydrate/reset (useAuthStore)
    └── Is it app-wide configuration (theme, defaults)?
        └── YES --> Pinia store with localStorage persistence (useSettingsStore)

Layer Details

1. Pinia Stores (app/stores/)

Pinia stores hold server-sourced domain data that multiple pages or components read. Most domain stores follow a minimal list + pagination shape. The auth store and settings store are exceptions with richer state and persistence logic.

Stores are data holders only. They do not contain API calls or business logic. That work belongs in domain composables (see below).

Component  -->  Domain Composable  -->  Service  -->  API
                     |
                     v
                Pinia Store (writes list + pagination)
                     ^
                     |
           Other Components (read store state)

See Pinia Store Patterns for conventions and examples.

2. Shared Composable State (app/composables/)

Some composables declare ref values outside the function body, at the module level. Because ES modules are singletons, every caller of the composable shares the same reactive state. This is the right pattern for app-wide UI state that does not come from the API:

  • useDialog -- modal dialog visibility and component
  • useSnackBar -- toast notification message and type
  • useConfirmDialog -- confirmation prompt with promise-based resolution

These composables are lightweight alternatives to a Pinia store when you need shared state but no devtools integration or persistence.

See Composable-Based State for the full pattern and examples.

3. Local Component State

State that only one component uses belongs inside that component's <script setup>. This includes:

  • Form field values (reactive<Partial<Doctor>>({...}))
  • UI toggles (ref(false) for showing/hiding a section)
  • Temporary loading and error states consumed inline
vue
<script setup lang="ts">
const showFilters = ref(false);

const form = reactive<Partial<Doctor>>({
  first_name: '',
  last_name: '',
  status: 'active',
});
</script>

Do not promote local state to a store unless a second component actually needs to read or write it.

Data Flow Architecture

The overall data flow in CPR follows a unidirectional pattern:

┌──────────────┐     ┌───────────────────┐     ┌──────────────────┐
│  Component   │────>│ Domain Composable │────>│  Service Class   │
│  (template)  │     │  (useDoctor)      │     │ (doctorService)  │
└──────┬───────┘     └────────┬──────────┘     └────────┬─────────┘
       │ reads                │ writes                  │ fetches
       v                     v                         v
┌──────────────┐     ┌───────────────────┐     ┌──────────────────┐
│ Pinia Store  │<────│   Store update    │     │   Backend API    │
│ (list, page) │     │   (list = data)   │     │  /api/doctors    │
└──────────────┘     └───────────────────┘     └──────────────────┘
  1. Component calls a domain composable function (e.g., getDoctors()).
  2. Domain composable calls the service class, manages loading/error/success refs.
  3. Service class performs the HTTP request via useApiFetch.
  4. On success, the composable writes the result into the Pinia store.
  5. Components reactively read from the store -- they never write to it directly.

When to Reach for Each Tool

ScenarioUse
Patient list displayed on /patients pageusePatientStore (Pinia)
Showing a create-doctor modal from a button anywhereuseDialog (shared composable)
Form fields for creating a new doctorreactive({...}) in the component
Current authenticated user and tokenuseAuthStore (Pinia + sessionStorage)
Toast after saving a recorduseSnackBar (shared composable)
"Are you sure?" before deletinguseConfirmDialog (shared composable)
Theme preference (dark/light)useSettingsStore (Pinia + localStorage)
Dropdown enum options (doctor roles, statuses)useEnumsStore (Pinia + API fetch)
Loading state for a single API callref(false) in a domain composable
Search/filter values for a list pageuseListFilters composable (local to page)

CPR - Clinical Patient Records