These conventions are intentionally opinionated. They exist to keep store APIs predictable, avoid subtle reactivity bugs, and make performance characteristics easy to reason about.
This project uses Svelte 5 runes-style stores for all new and modernized state.
Legacy writable stores may remain temporarily, but all new code should follow the patterns below.
Goals#
- Consistent store API across the app
- Fast reads in templates and services
- Clear separation between state, queries, and mutations
- Safe bridging for legacy
subscribestores during migration - Avoid accidental reactivity pitfalls and performance cliffs
1) Store shape: .state, .read, .actions#
Every runes-style store exports a single object with three clearly defined sections:
export const someStore = {
state, // mutable reactive state ($state)
read, // pure query helpers (no side effects)
actions, // mutations, async operations, IO, caching
};Components should never mutate store state directly. All mutations must go through
.actions.
.state (reactive, mutable)#
Rules:
- Only
$state(...)objects and primitive fields - Keep it small and normalized
- Include
loading/errorfields if the store does IO
Example:
const state = $state({
items: [] as Item[],
byId: new Map<string, Item>(),
loading: false,
error: null as string | null,
lastUpdatedAt: 0,
});Prefer indexes (
Map,Set) over repeatedly scanning arrays in.read.
.read (pure queries)#
Rules:
- No side effects (no network, no writes)
- Takes inputs and returns derived values
- May read from
state, but must not mutate it - Prefer stable return types when possible
const read = {
getById(id: string) {
return state.byId.get(id);
},
has(id: string) {
return state.byId.has(id);
},
};If a function needs to fetch data, update state, or trigger effects, it does not belong in
.read.
.actions (mutations + IO)#
Rules:
The only place that:
- mutates
state - triggers network calls
- does caching or memoization
- orchestrates multi-step updates
- mutates
const actions = {
async load() { /* ... */ },
setItems(items: Item[]) { /* ... */ },
clear() { /* ... */ },
};2) “Mode” pattern for stores with multiple datasets#
If a store supports multiple datasets (e.g. active vs historical), use an explicit mode.
type Mode = "active" | "all";
const state = $state({
mode: "active" as Mode,
activeLoaded: false,
historicalLoaded: false,
historicalLoading: false,
});Canonical API:
actions.setMode(mode: Mode)- switches the active view
- auto-loads required data (idempotent)
actions.ensureHistoricalLoaded()- safe to call repeatedly
read.*Current*functions are mode-aware
“Current” always means whatever the active mode points at. Components should not need to know about internal indexing details.
3) Idempotent async actions (no duplicate loads)#
Every async loader must be safe to call multiple times.
Canonical pattern:
let historicalPromise: Promise<void> | null = null;
async function ensureHistoricalLoaded(): Promise<void> {
if (state.historicalLoaded) return;
if (historicalPromise) return historicalPromise;
state.historicalLoading = true;
historicalPromise = (async () => {
try {
const data = await fetchHistorical();
// merge into indexes
state.historicalLoaded = true;
} catch (e) {
state.error = e instanceof Error ? e.message : String(e);
throw e;
} finally {
state.historicalLoading = false;
historicalPromise = null;
}
})();
return historicalPromise;
}This allows components to safely do:
void store.actions.ensureHistoricalLoaded();without coordinating or checking flags first.
4) Canonical normalization: indexes vs lists#
If you need fast lookups, store indexes in state:
byId: Map<string, T>idsByGroup: Map<string, string[]>ids: string[](optional)
Avoid storing the same data in multiple heavyweight forms unless necessary.
Update related structures atomically:
function setItems(items: Item[]) {
state.items = items;
state.byId = new Map(items.map(i => [i.id, i]));
}5) Reactive collections: use them deliberately#
Default rule#
Use plain Map / Set in $state unless you need mutation-level reactivity.
- If you replace the whole map/set → plain
Map/Set - If you mutate frequently and need fine-grained reactivity →
SvelteMap/SvelteSet
Canonical options#
Option A — immutable updates (recommended default)
state.byId = new Map(state.byId).set(id, item);Option B — reactive collections (use sparingly)
import { SvelteMap } from "svelte/reactivity";
state.byId = new SvelteMap();
state.byId.set(id, item);Blindly converting everything to
SvelteMap/SvelteSetcan cause serious performance regressions. Use them only where mutation-level reactivity is actually required.
6) Cross-store reads#
When Store A needs data from Store B:
- Prefer
storeB.read.* - Avoid poking
storeB.stateunless it’s a cheap, read-only check
const course = courseIndexStore.read.resolveByInstanceId(id);7) Component integration#
Reading state in templates#
{#if courseIndexStore.state.historicalLoading}
<p>Loading…</p>
{/if}Lookups and derived logic#
const resolveCourse = (id: string) =>
courseIndexStore.read.resolveByInstanceId(id);Effects trigger actions (fire-and-forget)#
$effect(() => {
if (needsHistorical) {
void courseIndexStore.actions.ensureHistoricalLoaded();
}
});Prefer
voidfor fire-and-forget actions. Rendering should never block on store IO.
8) Bridging legacy writable stores#
During migration, legacy writable stores may still exist.
Canonical utilities:
type Unsubscriber = () => void;
type Subscribable<T> = { subscribe(run: (value: T) => void): Unsubscriber };
export function isSubscribable<T>(x: unknown): x is Subscribable<T> {
return !!x && typeof (x as { subscribe?: unknown }).subscribe === "function";
}Canonical bridge:
$effect(() => {
const unsubs: Unsubscriber[] = [];
if (isSubscribable<PlansStoreValue>(plansStore)) {
unsubs.push(plansStore.subscribe(v => {
// copy into local $state
}));
}
return () => unsubs.forEach(u => u());
});Bridge once, then use local
$state. Do not spread legacy.subscribeusage throughout the component.
9) Error and loading fields#
If a store performs IO, include:
loading: booleanerror: string | null
For multiple loaders, be explicit:
historicalLoading,historicalLoadedactiveLoading,activeLoaded
Actions should:
- set loading flags before/after
- set
erroron failure - rethrow only when the caller needs to react
10) Naming conventions#
Files:
- rune store:
fooStore.svelte.ts - legacy store (temporary):
fooStore.ts
- rune store:
Public API:
.read.resolveById,.read.getCurrent….actions.load,.actions.ensure…,.actions.set…,.actions.clear
“Current” always means mode-aware
Checklist for new or updated stores#
- Exports
{ state, read, actions } - No side effects in
.read - All mutations happen via
.actions - Async loads are idempotent (deduped promise)
- Indexes updated atomically
- Reactive collections used only when justified
- Legacy stores bridged locally, not leaked
- Clear loading/error flags