Adding a setting or preference
A preference is a small, single value the user sets and the app reads - a toggle, a colour, a token, a
timestamp. It lives in the settings slice at runtime and persists through PreferenceService, which
writes one file per key. Full reference: docs/Storage.md.
Not every persisted value belongs here. Anything the user creates in quantity, anything queried or
deleted by id, and anything that must survive a backup/restore belongs in SQLite instead - see
docs/Storage.md and the add-storage-migration skill.
The one place that changes: the registry
Add a single entry to preferenceRegistry in app/src/store/settings/registry.ts:
export const preferenceRegistry = {
// …
showPostWorkoutSummary: pref({ default: false, codec: boolCodec }),
} satisfies Record<string, PrefDescriptor<unknown>>;
From that one entry the system derives, automatically:
- the
SettingsStatefield and its type, - the
initialStatedefault, - a typed action creator named
setShowPostWorkoutSummary(the historicalset<Capitalize<key>>name), reduced by a single matcher, - generic hydration (read on launch) and generic persistence (write-back on change, guarded by
isHydrated).
Then do the two things that can't be derived:
- Re-export the setter. ESM can't spread named exports, so add
setShowPostWorkoutSummaryto the destructuredexport const { … } = preferenceSetters;list inapp/src/store/settings/index.ts. This is compiler-enforced - omit it and the call sites fail to typecheck. - The settings UI - usually a
ListSwitchinapp/src/app/(tabs)/settings/app-configuration.tsx, readinguseAppSelector((state: RootState) => state.settings)and dispatching the action. Labels are Tolgee keys (<T keyName="…" />); add<area>.<thing>.labeland.subtitletoapp/src/i18n/en.jsononly - other languages come from Weblate. Seeapp/src/i18n/README.md.
That's it - no PreferenceService method, no hydration wiring, no per-key write-back effect.
Choosing a codec
The codec (in app/src/store/settings/codecs.ts) maps the value to/from its on-disk string. deserialize
returns undefined for an absent/unparseable key (the descriptor default then wins); serialize
returns undefined to remove the key. Reuse an existing one where possible:
boolCodec-'True'/'False'(historical casing, don't "fix" it).intCodec-parseInt, falls back to the default onNaN.stringCodec- passthrough; anundefinedvalue removes the key.colorSchemeSeedCodec,dayOfWeekCodec,instantCodec- worked examples of validated / typed values.
For a new value shape, add a Codec<T> next to these. The codec owns the encoding and the
back-compat story: if you change how an existing key is stored, its deserialize must still read the
old form (see getPreferredLanguage, which rewrites the legacy zh_Hans value on read).
Escape hatches for bespoke keys
Most keys need nothing beyond { default, codec }. A PrefDescriptor also accepts:
persist: false- the generic write-back skips the key; a dedicated effect inapp/src/store/settings/effects.tsowns saving it. Use for permission gates (exportToHealthAggregator), dev-only guards (proToken), or values spread across several storage keys (remoteBackupSettings).hydrate: 'manual'- the init effect reads the key explicitly instead of the generic loop. Use for sync reads, composite keys, or values composed from multiple keys (lastBackup).sync: true- read viagetItemSyncduring hydration (preferredLanguage, needed before the store exists for Tolgee).storageKey- override the on-disk key name (defaults to the registry key).
A bespoke key with no codec (e.g. remoteBackupSettings, lastBackup) is state-only from the
registry's view: keep its custom get/set methods on PreferenceService and wire its hydrate/persist by
hand in effects.ts.
Traps
- One default, one source. The default lives only in the descriptor, so the pre-hydration value and the never-written value can no longer drift apart (they used to).
- Preferences are not backed up.
getBackupBytesserializes the SQLite database only, so a preference does not survive export/restore or remote backup. If the user would be upset to lose it on a device switch, it belongs in the DB. - Don't read preferences from components. Components read
state.settings; only effects touchpreferenceService. The sanctioned reads outside effects aregetPreferencein data-migrations and the syncgetPreferredLanguagefor Tolgee. setProTokenno-ops in__DEV__. Expected, not a bug - that's why it's a bespokepersist: falsekey rather than a generic one.
Removing a preference
Delete the registry entry and its re-export line (typecheck will point at any remaining call sites), plus its UI. Don't bother deleting the on-disk file - an orphaned key in the document directory is harmless.
Verify
From app/: npm run typecheck, then npm run lint. app/src/services/preference-service.spec.ts
characterizes the storage encodings and app/src/store/settings/preferences.spec.ts covers the generated
action + generic hydrate/persist - extend them if you add a new codec or a bespoke key. A straight
round-trip preference otherwise needs no new test; if it drives behaviour elsewhere, test that.