Skip to main content

State management

The apps avoid a global state library. State falls into three categories, most of it living in @enode/core.

1. Prefetched reference data — external stores​

Caches like metrics, text-content, user-app-settings each use the same pattern (packages/core/src/<domain>/):

  • a module-level store (store.ts: Map + listener set + loadX/clearX),
  • read through useSyncExternalStore hooks (hooks.ts),
  • an <XLoader/> mounted in each app's layout.tsx.

Rule: don't add React state for prefetched reference data — extend the store. These return DTOs stay read-only by design.

A reference catalogue is loaded once per launch and is otherwise not revalidated: it changes on a release cadence, so refetching it whenever the app is foregrounded would cost far more than the staleness it removes. The exception is tracking/store.ts, which registers with registerRevalidation (packages/core/src/store-kit/revalidation.ts) purely as a recovery trigger — it retries on foreground/reconnect only when an earlier load came back with no data, and skips both the idle state (never asked; the portal shares this core and never preloads the catalogue) and any populated one. Without it a device that booted faster than its network came up had no sensor configuration until someone restarted the app by hand, which the tracking home reports as a blocking dialog (apps/tracking/src/app/workouts/today/tracking-settings-guard.tsx).

The same pattern also backs per-key caches of user-scoped reads, where the key is an id or an id + window rather than a whole catalogue:

  • users/profile-store.ts — one full user profile per id (GET /users/{id}). Shared by the user drawer's fetch-before-edit gate and its Summary + Goals tabs, so one open issues one read. Deliberately not registered for focus/reconnect revalidation: refetching a profile that is open in an editor risks clobbering in-progress input.
  • users/week-stats-store.ts — weekly history stats per athlete + window.
  • users/activity-store.ts — one athlete's completed sessions per week.

Each exports an invalidateX(userID) that REFRESHES the cached entry in place, plus a forgetX(userID) that drops it (for a deleted entity, where a refetch would 404). Refreshing rather than dropping is load-bearing: these caches are read through hooks that load from an effect keyed on the id, so a dropped entry has nothing left to re-trigger the fetch and a mounted view waits on a skeleton forever. Same lesson as workouts/store.ts's invalidateMonth. Invalidation is not always the resource you wrote: saving an athlete's muscle goals changes the targets the backend computes their week stats from, so that write invalidates the stats too. See user-drawer-data-flow.md.

2. Training write path — domain models​

The session / set / rep / measurement write path uses the domain models in packages/core/src/training/:

  • models.ts — WorkoutSession, WorkoutSet, WorkoutRep, Measurement.
  • immutable set/session mutations.
  • builds reps from sensor events.

Models map to wire DTOs via core's api/mappers (toSessionCreateDto) only at upload time, inside api/.

3. Local UI / feature state — hooks​

App feature views keep transient UI state in co-located use-* hooks and React state. State resets use the render-time prev-value pattern, not setState in useEffect.

Read path vs. write path (summary)​

DirectionRepresentationCrosses to DTO…
Read (reference data)return DTOs, read-onlyn/a
Write (training)training/ domain modelsonly inside core's api/, at upload

TODO: the workout return-DTO side becomes a domain model when workout editing (editable items/blocks) is built — deferred, not rejected.