Part 13 confessed it in passing: `£${(n ?? 0).toFixed(2)}` — the pound sign, hardcoded in a template string, in a file literally named money.ts. A reasonable MVP shortcut for a single-region app.
Then the app grew currency support for people who shop in naira, cedis, shillings and rand — and that one character became an audit. Currency isn't a constant. It's a user property masquerading as one — and it was hiding in more places than any grep for £ suggested.
TL;DR — Model home currency on the user (
UserProfile.homeCurrency), not the device. The interesting problem is that two kinds of code read it: React components need a subscription (Redux, viauseMoney()— that's what re-renders every price on change), while pure functions outside React — selectors, chart formatters — need a module-level mirror (setHomeCurrency()). One setter updates both; skipping either leaves half the app in the old currency. Cache the pref so the first frame never flashes the wrong symbol; on first run guess the country from the device locale but commit only on Continue; and when the user changes currency, relabel, never convert — and say so in the dialog.
(Part 17 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
The audit
The grep for £ was the easy part. The full list of places a currency assumption hides, from this codebase:
- the two core formatters (
formatMoney,formatPennies) - the compact chart-label format (
£1.2k) from Part 16 - the AmountSheet's currency symbol next to its numeric input
- input placeholders and empty-state copy ("Set a budget like £30")
- receipt rows and month subtotals
- savings headlines ("Save £3.20")
One preference, a dozen leaks. Which is the argument for fixing it as a sweep with a design, not incrementally: every incremental fix invents its own way to learn the currency, and you end up with three.
The model: on the user, not the device
Two fields on the profile row: homeCurrency (ISO 4217) and countryCode. Per-user, not per-device — sign in on a new phone and your naira comes with you. The device's locale is a hint (more below), never the storage.
The two-readers problem
Here's the architectural heart. Code that formats money comes in two species:
Components need reactivity: when the preference changes, every visible price must re-render. So components use a useMoney() hook that reads state.prefs.homeCurrency from Redux — the subscription is the re-render mechanism.
Pure functions — the expenses selectors, chart label formatters, anything callable outside a component — can't subscribe to anything. For them, money.ts keeps a module-level mirror. The comment in the real file is the design note:
/**
* Module-level mirror of the user's preference, set once at launch and on
* change via setHomeCurrency(). Kept here so the pure formatters below stay
* callable from non-component code (selectors, helpers) with no plumbing.
* Components read `state.prefs.homeCurrency` from Redux — that's what makes a
* change actually re-render; this mirror only keeps the formatting correct.
*/
let homeCurrency = DEFAULT_HOME_CURRENCY;
export function setHomeCurrency(code?: string | null) {
homeCurrency = normaliseCurrency(code) ?? DEFAULT_HOME_CURRENCY;
}
The failure mode this prevents: update Redux but not the mirror and your components relabel while your chart labels and selector output stay in pounds — half an app in each currency. So there is exactly one entry point, applyCurrencyPref(), which dispatches to Redux and calls setHomeCurrency() and persists the cache. Two stores, one setter, no drift.
(If your state library exposes reading outside React — store.getState() — you could skip the mirror; the trade is that every pure helper grows a store import. The mirror keeps money.ts dependency-free, which is also what keeps it trivially testable.)
Never flash the wrong symbol
The durable source of truth is the profile row — which arrives over the network. Format from that alone and every launch renders £ for a second or two before snapping to ₦. The fix is a cache with a stated job, from the real file header:
// Home-currency preference. UserProfile is the durable source of truth;
// AsyncStorage caches it so formatting is correct on the very first frame,
// before the profile query resolves (otherwise every amount would flash £).
Launch applies the cached pref immediately; a small CurrencySync component reconciles once the profile loads. A flickering currency symbol is a tiny bug that reads as a deeply untrustworthy app — it's money wearing the wrong flag.
First run: guess, then confirm
New users get a one-time country picker, and it opens pre-aimed: the device's region floats to the top of the suggestions. The detection is one honest function:
// …saved until they tap Continue. Kept a function, not a module constant, so
// the native read happens on mount rather than at import.
function detectCountry(): CountryCurrency | null {
const region = getLocales()[0]?.regionCode;
if (!region) return null;
return COUNTRY_CURRENCIES.find((c) => c.countryCode === region) ?? null;
}
Two deliberate choices in those six lines. It's a function, not a module constant — the React Compiler (rightly) rejects impure native reads in render, and an import-time read would run before the native module is warm; the lazy useState(detectCountry) initializer runs it exactly once, on mount. And the result is a pre-filled guess, not a decision — it commits only when the user taps Continue (or Skip, which applies the default). Guessing saves the 95% case a scroll through 200 countries; confirming saves the traveler whose phone thinks they live at the airport.
One more belt-and-braces detail from the pref module: completion is also flagged locally, because if the profile write fails (offline, transient), the user must not be re-prompted on every launch — "the flag is the belt, homeCurrency the braces."
Relabel, never convert
The sharpest product decision in the feature is what changing your currency doesn't do. It relabels — 1.30 that was £1.30 is now ₦1.30 — and the confirmation dialog says exactly that, in words, before you confirm.
Converting would require an FX rate, a rate date, and an answer to "converted as of when?" for every historical amount — silently, invisibly, behind a settings toggle. Relabeling is honest about being dumb; conversion would be dishonest about being smart. The supporting cast from earlier posts follows the same line: receipts keep the currency they were recorded in forever, month subtotals stay per-currency, and Part 16's trends exclude-and-count foreign receipts rather than blending them. Actual FX conversion has a written plan doc and deliberately zero code — it only earns its complexity if foreign receipts actually accumulate.
The sibling audit: dates
The same sweep centralised every date operation into one dayjs module, and its header comment carries the war story:
/**
* Every date format and calculation in the app, on dayjs.
*
* - `new Date("2026-07-21")` parses as UTC midnight, which renders as the
* PREVIOUS day anywhere west of Greenwich — a receipt dated the 21st showing
* as the 20th. `dayjs("2026-07-21")` parses as local, so `parseISODate` can't
* reproduce that bug.
* - The month names, "14 Jul" and "July 2026" had drifted into seven separate
* copies. One definition means one place to change.
*/
Date-only strings are timezone landmines: new Date("2026-07-21") is UTC midnight, which is yesterday evening in New York — a receipt's purchase date (that carefully timezone-proofed AWSDate from Part 14) would have shifted a day at the very last step, in the render. And the same React Compiler purity rule made a second cameo: anything reading the clock stays a function, never a module constant — today() computed at import time is a stale date by breakfast.
What I took away
- User properties masquerade as constants. Currency, locale, units, first-day-of-week — if two users could disagree, it's a preference with storage, not a literal.
-
Two kinds of readers need two stores — behind one setter. Subscriptions for components, a module mirror for pure functions, and a single
applyCurrencyPref()so they can't drift. - Cache-first display or the first frame lies. Money flashing the wrong symbol is trust damage out of proportion to the bug.
- Guess, then confirm. Device locale aims the picker; only the user commits it.
- Relabel ≠ convert — say so in the UI. The honest dumb behavior beats the silent clever one.
-
Never
new Date("YYYY-MM-DD"). One centralised date module, and the clock stays a function.
Next up
Every amount in the app now wears the right symbol — but there's a deeper question hiding under the Expenses tab: when did you actually buy that? Ticking an item and scanning a receipt disagree about time in a subtle way, and resolving it produced the app's purchase ledger. That's next.
What's hardcoded in your app right now that's actually a user preference? Be honest — mine was in a file called money.ts.


Top comments (0)