This document describes how client-side stores in this codebase are structured. It is derived from app/_stores/pomodoroStore.ts (Valtio). Follow it for new or heavily edited stores unless a file already establishes a different, consistent convention.
- Use Valtio:
proxyfor the mutable state object,useSnapshotin React hooks for reactive reads, andsubscribefromvaltio/vanillafor side effects (e.g. persistence) that should not run on every render.
Store script layout is mandatory. Keep sections in this order so every store file reads the same way:
| Order | Block | Contents |
|---|---|---|
| 1 | Imports | Valtio, valtio/vanilla, app libs (logger). |
| 2 | Module-level constants | SCREAMING_SNAKE_CASE keys, defaults, limits, domain constants. |
| 3 | proxy state |
Single private proxy({ ... }). |
| 4 | Exported types | export type for domain and persisted shapes (V1 suffix where applicable). Order: composite / “main” types first, then smaller types they compose (see Exported types below). |
| 5 | Exported hooks | use* functions using useSnapshot only. |
| 6 | Other small exported helpers | Pure utilities consumers need (e.g. localDayKey) — keep minimal. |
| 7 | Actions object | const featureActions = { ... } — init must be the first method declared (see below). |
| 8 | Private domain logic | Predicates, transitions, duration math, helpers (typically Snapshot<T> in arguments), etc. |
| 9 | Persistence (end of file) | Load/parse, pick/apply persisted shapes, localStorage I/O, dirty JSON tracking, type guards used only for persistence — always last, after all other private helpers. |
Do not bury persistence in the middle of the file.
Typical order (group with blank lines as in the reference store):
valtio—proxy,useSnapshot, andtype Snapshotwhen you type snapshot-based helpers.valtio/vanilla—subscribe(and any other vanilla APIs).- App libs — e.g.
@/lib/loggerforlog.error/log.warn(never swallow storage or parse errors silently).
Use the shared logger instead of raw console.* so levels stay consistent.
These bindings are constants (not derived at runtime). Declare them above the proxy:
- Versioned storage keys — namespaced and versioned, e.g.
deepdash.pomodoro.config.v1, so migrations and collisions stay manageable. - Defaults, limits, and domain constants — durations, thresholds, etc.
Naming: every such constant must use SCREAMING_SNAKE_CASE (e.g. CONFIG_KEY, DEFAULT_WORK_MS, MAX_WORK_MINUTES, WORK_BLOCKS_BEFORE_LONG_BREAK).
Do not place functions between the constants block and the proxy (e.g. localStorage key builders). Those belong in Persistence at the end of the file, next to read/write helpers, even if they only use module-level prefix constants declared above the proxy.
- Hold one
const myStore = proxy({ ... })per module. - Never export the proxy. Use it only in exported reactive hooks (via
useSnapshot) and inside action methods (read and mutate the proxy directly). - Initialize fields with explicit types where inference is too wide (
as PomodoroPhase,as Record<...>,null as T | null). - Document non-obvious flags and shapes with
/** ... */on the property (e.g.hydratedgating persistence, whatactivePhaseRunmeans vs idle).
export typefor anything returned from React hooks (and supporting shapes those return values use): phases, persisted V1 shapes (PomodoroConfigV1,PomodoroDayLogV1), in-memory structures (ActivePhaseRun, etc.).- Suffix persisted JSON/document types with
V1(or bump when the schema changes — align with AGENTS.md export/import rules if the data is part of app export). - Order (main before parts): declare larger or composite types first, then smaller types that appear as fields inside them (e.g.
TodoDayDocumentV1beforeTodoItem). TypeScript allows forward references within the same file when a composite type mentions a part type declared just below it.
use* hooks are the only supported way for React components to read stored data. Do not read the proxy from components; subscribe through hooks.
- Export named functions
useThing(): Snapshot<T>that calluseSnapshot(store)and return a primitive, derived value, or narrow slice so components re-render when that slice changes. - Put derived logic in the hook body when it is UI-facing (e.g.
useSecondsRemaining,useTodayWorkMsDisplay).
Actions are the only supported way for React components to request state changes. Do not expose ad-hoc mutators; components read via hooks and command via featureActions.
- Do not call
useSnapshotinside an action. Do useuseSnapshotinside exported hooks. Mutate theproxystate directly inside actions (Valtio tracks mutations and will trigger all use* hooks that use the mutated data). - Prefer meaningful action methods that encode real state transitions (start, pause, finalize phase, etc.). Avoid thin setters except where unavoidable — typically configuration fields that need clamping or validation.
- Export a single
const featureActions = { ... }with named methods (function name()syntax inside the object is fine for stack traces). - Inside any
subscribecallback used for persistence, guard onhydrated(or equivalent) before writing.
Every store that persists to localStorage must expose an init method as the first property on the actions object. It must:
- Load persisted data into the proxy (e.g.
loadFromStorage()), including hydration flags and resetting ephemeral state as needed. - Subscribe to store changes and, when allowed (e.g. after
hydrated), updatelocalStorage. - Return the
subscribeunsubscribe function so callers (e.g.useEffectin a root component) canreturnit on unmount and stop persistence callbacks.
Pattern:
init: function init(): () => void {
loadFromStorage();
return subscribe(myStore, () => {
if (!myStore.hydrated) return;
persistIfChanged();
});
},Stores that participate in lib/dataExport.ts must also expose:
exportData()— returns a versioned slice plain object, e.g.{ version: 1, … }, suitable for JSON. Use a module constantFEATURE_EXPORT_VERSIONand an exported typeFeatureExportV1(bump names when the slice shape changes, e.g.FeatureExportV2).importData(data: unknown)— accepts any supported slice shape for that feature (including legacy objects without aversionfield when you still support older backups). Updates the proxy,localStorage, and anylast…Jsondirty-tracking strings so persistence stays consistent.
Place exportData / importData on featureActions after domain methods; init remains the first action.
Also export a pure migrator:
migrateFeatureSliceToLatest(data: unknown): FeatureExportV1(name by feature) — nolocalStorageside effects; normalizes unknown JSON to the current slice type. Used byimportData, bytryMigrateDeepdashBundleinlib/dataExport.ts, and by Jest. Branch ondata.version(and on legacy shapes) here; throw a clear error if the slice version is unsupported.
Implement migrateFeatureSliceToLatest and slice-specific helpers in the Persistence section at the end of the file (or a // --- bundle export/import --- subsection there). lib/dataExport.ts should only assemble the top-level bundle (version, exportedAt) and delegate slices to each store — do not duplicate per-feature parse/migrate logic outside the store.
- Pure predicates and domain helpers — e.g.
isRunning,durationForPhase, transition helpers. Typically takeSnapshot<T>in arguments so the same function can be called from hooks (snapshotted data) and from actions (proxy state is assignable for reads). Snapshot<T>is type-compatible withTfor field access, so one helper avoids duplication across hooks and actions.- Private setters only where they centralize validation (often config clamps). Do not use them as a public alternative to actions.
- Section comments for long non-persistence regions: e.g.
// --- time-related helpers ---.
(Placed at the end of the store file — see File layout.)
Include here private helpers that only exist for persistence, such as functions that build localStorage key strings from the module-level prefix constants (those constants stay above the proxy; the builders live here).
Stores use localStorage for persistence.
hydratedflag:subscribehandlers must not write tolocalStorageuntilloadFromStorage()has finished — defaults in memory would overwrite the user’s saved data. After load, sethydrated: trueonly once parsing, application of stored data, and any reset of ephemeral fields are complete.typeof window === "undefined"early-return for anylocalStorageaccess (static export / SSR safety).try/catcharound read and write paths; log withlog.errororlog.warnand include context (feature prefix, key, error).- Parse with
JSON.parse→unknown, then validate withisRecord, narrowing type guards (isPomodoroPhase), and per-field parsers that returnnullor skip invalid entries rather than throwing. - Clamp and fallback loaded numbers (e.g.
clampPositiveMs) so bad data cannot corrupt the store. - Dirty tracking: keep
lastConfigJson/lastLogsJson(or similar) strings;JSON.stringifythe picked persist shape, compare, then write only when changed to avoid churn. pickPersistedX(): build a plain serializable object from the proxy (spread / map) so you do not persist proxies or accidental references.applyXRecord(parsed): apply validated fields to the store with the same validation as private setters where possible.
Do not leave catch empty. Log at least a warning (e.g. log.warn) or log.error when the failure is exceptional, and include context.
Match semicolons, trailing commas, and naming (useCurrentPhase, pomodoroActions.selectPhase) to the file you are editing and to sibling stores in app/_stores/.