ADR-013: Frontend state management — Svelte 5 runes in `.svelte.ts` singletons
ADR-013: Frontend state management — Svelte 5 runes in .svelte.ts singletons
Status
Accepted
Context
The frontend (SvelteKit 2 + Svelte 5, SPA mode — frontend-architecture.md §1) has shared state that crosses the shell, the room framework, and rooms: playback state, the queue, session search, library, plugin status, toasts, settings. The queue is already frontend-authoritative in “Svelte state” (ADR-010), but no policy exists for how all shared state is built.
Svelte 5 offers two reactivity models: legacy writable/readable/derived
stores and the $state/$derived/$effect rune system. Runes work in plain
.svelte.ts modules (module scope), not just components — they can model
state outside the component tree, which ADR-012 requires (state hoisting so
rooms can unmount without losing state).
Decision
Shared frontend state is built on Svelte 5 runes, in *.svelte.ts
modules. Legacy stores are not used for new code; if a need for one arises
(external store interop, StartStopNotifier semantics), it is a localized
exception, not a precedent.
The canonical pattern (frontend-architecture.md §5.1):
- A class with
$statefields, exported as a singleton instance. The instance is a stable reference — every importer sees the same reactive object, the runes-era equivalent of a store without subscription machinery. $statefor fields,$derived/$derived.byfor computed values, methods for actions.$effectis reserved for genuine side effects (e.g. announcing now-playing to screen readers) — never to sync state to state.- Files that use runes at the top level are named
*.svelte.ts(mandatory — runes run in module scope). - Export singletons, never raw
$stateprimitives — that loses the stable-reference benefit and invites re-assignment bugs. - State modules may call the IPC bridge and other state modules; they must
not import components, routes, or
$app/*.
Alternatives considered
- Legacy stores (
writable/derived). Work, but the two reasons to reach for them — external store interop andStartStopNotifiersemantics — do not arise in v1. They add subscription machinery the rune system already makes unnecessary and split the codebase across two reactivity models. - Third-party signals / global state (Zustand-style). An extra dependency
to recreate what Svelte 5 runes provide natively; loses Svelte’s
framework-integrated
$derivedand template tracking. - Non-reactive module singletons (plain exported objects + manual notification). No reactivity; components would hand-subscribe. Rejected.
- Component-context-only state (
setContext/getContext). Cannot outlive a route’s unmount, so it cannot carry the FR-ROOM-1 preserved-state contract that ADR-012 depends on.
Consequences
Positive:
- Single reactivity model across the frontend; no store/rune translation layer for rooms or components.
- State survives route unmount (module scope), which is what makes ADR-012’s unmount + rehydrate model work.
- State modules are plain modules — testable in Vitest outside components, with the IPC bridge mocked (frontend-architecture.md §13).
- Follows ADR-010’s existing direction (the frontend already owns the authoritative queue in Svelte state).
Negative:
- Rune usage at module scope requires the
*.svelte.tsextension; forgetting it produces hard-to-trace Svelte 5 errors. Enforced by convention + review. $effectis a footgun (runs after render, reacts to any accessed state); the policy restricts it to genuine side effects.- Svelte 5 runes are newer than the legacy stores many contributors know; the canonical module pattern (§5.1) is the reference to copy.
References
frontend-architecture.md§5 (state layer — runes, not stores), §5.1/§5.2 (canonical module pattern, state module inventory), §13 (testing)- ADR-010 (queue management — frontend-authoritative; Svelte state precedent)
- ADR-012 (room lifecycle — unmount + state hoisting, the consumer of this policy)