●PRODUCT — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — Rork Max unlocks AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CLASSIC — The original Rork uses React Native (Expo), turning plain-English prompts into shippable iOS/Android apps●FUNDING — Rork raised $2.8M from a16z (plus $15M more), reaching 743,000 monthly visits at 85% growth●PRICING — Rork is free to start, with paid plans from $25/month; Rork Max is $200/month●CHOICE — Pick cross-platform Rork or Rork Max for deep Apple-native capabilities, depending on your goal●PRODUCT — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — Rork Max unlocks AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CLASSIC — The original Rork uses React Native (Expo), turning plain-English prompts into shippable iOS/Android apps●FUNDING — Rork raised $2.8M from a16z (plus $15M more), reaching 743,000 monthly visits at 85% growth●PRICING — Rork is free to start, with paid plans from $25/month; Rork Max is $200/month●CHOICE — Pick cross-platform Rork or Rork Max for deep Apple-native capabilities, depending on your goal
Why Your Prices Show as “¥1234” on Some Phones — A Formatting Layer That Doesn't Trust Intl Alone in Expo
Same code, yet one device shows “¥1,480” and another “¥1480.” In Expo / Hermes, Intl leans on each device's locale data, so output can drift by OS and engine. Here is a resilient formatting layer that centralizes currency, number, date, and timezone handling and falls back gracefully where Intl is incomplete — with working code.
One morning a review on one of my apps said simply: "the price looks broken." The screenshot showed "¥1480" where it should have read "¥1,480" — the thousands separator had vanished. I could not reproduce it on my own test device. Not a single line of code had changed.
The cause was not the code; it was the locale data each device carries. React Native (Expo) apps usually run on the Hermes engine, and its Intl implementation leans on the device's internationalization data (ICU). So the very same Intl.NumberFormat call can produce subtly different strings depending on the OS version and language settings.
As an indie developer shipping several apps to different regions, this "bug that never happens on my phone" is the most stubborn kind. Today I want to lay out the formatting layer I actually use — one that stops trusting Intl blindly and catches breakage before release.
Why "the same code" renders differently
Intl is the ECMAScript internationalization API, but the locale data it needs — currency symbol placement, grouping separators, month names, timezone rules — is provided by the runtime. Hermes bridges that part to the device's ICU, which leaves three variables in play.
First, the OS version. Android uses the ICU bundled with the OS, so older devices carry older locale data and may lack newer currencies or regional formats. Second, the user's language and region, since the default locale derived from the system can be changed freely. Third, the engine build configuration — whether Intl is included, and how much of it.
The key insight is that Intl is not a binary "present or absent." It is "present, but with different reach per feature." NumberFormat works broadly, yet passing an arbitrary IANA name (like Asia/Tokyo) to DateTimeFormat's timeZone is silently ignored on some devices. Partial gaps like this are the real-world failure mode.
Funnel every format call through one place
The first move is to ban scattered toLocaleString() calls and hand-rolled grouping, and route all formatting through a single file. When something does break, there is exactly one place to fix.
// lib/format.ts — every format call in the app goes through hereimport { getLocales } from "expo-localization";// Device's primary language; fall back to Japanese if unavailableexport const APP_LOCALE = getLocales()[0]?.languageTag ?? "ja-JP";// Decide once at startup whether Intl is genuinely usableconst hasIntl = typeof Intl !== "undefined" && typeof Intl.NumberFormat === "function" && // confirm the currency style itself does not throw (() => { try { new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" }).format(1); return true; } catch { return false; } })();export function formatNumber(value: number, locale: string = APP_LOCALE): string { if (hasIntl) { return new Intl.NumberFormat(locale).format(value); } // Fallback: naive grouping every three digits return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");}
The point is evaluating hasIntl once, inside a try/catch. Even when the Intl object exists, a rare device throws on specific options like style: "currency", so I verify it is actually callable before relying on it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why Intl.NumberFormat / DateTimeFormat change output across device, OS, and engine — and how to catch the breakage before shipping
✦A single format module for currency, numbers, dates, and relative time, with working fallback code
✦Graceful degradation for devices where arbitrary IANA timezones are ignored, plus a store-UTC / display-locale separation
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Currency is where price displays break most often. The currency style of Intl.NumberFormat is determined by two things: the display locale and the currency code. Leave either vague and you get exactly the review from the opening — a missing separator or a misplaced symbol.
// lib/format.ts (continued)export function formatCurrency( amountMinor: number, // store in minor units (yen as yen, dollars as cents) currency: string, // "JPY" / "USD", ISO 4217 locale: string = APP_LOCALE): string { // Let Intl decide fraction digits per currency (JPY 0, USD 2) const major = currency === "JPY" || currency === "KRW" ? amountMinor : amountMinor / 100; if (hasIntl) { try { return new Intl.NumberFormat(locale, { style: "currency", currency, currencyDisplay: "symbol", }).format(major); } catch { // fall back only when the currency code is unknown, etc. } } const num = formatNumber(major, locale); return currency === "JPY" ? `¥${num}` : `${num} ${currency}`;}
What you want to avoid is concatenating "¥" yourself and inserting separators by hand. The Before / After below is a typical case I actually rewrote.
Aspect
Before (manual concat)
After (format layer)
Grouping
Comma drops on some Intl builds
Controlled in one place; fallback uses the same rule
Symbol
Hard-coded "¥", no dollar support
Switches by currency code
Fraction digits
Yen and dollars share digits
Decided per currency by Intl
Match with App Store
Looks off and untrustworthy
Aligned to the locale
When the numbers around billing disagree with the store's own display, it dents conversion all by itself. I have seen support questions drop simply from tidying how prices look, so I treat this as a place not to cut corners.
Keep dates "stored in UTC, shown in locale"
Date and time bugs multiply the moment you mix storage and display. The rule is simple: always keep UTC (ISO 8601) in storage and on the server, and convert to the locale only at the instant of display.
// lib/format.ts (continued)export function formatDate( iso: string, // e.g. "2026-06-23T02:00:00Z" (UTC) locale: string = APP_LOCALE, timeZone?: string // e.g. "Asia/Tokyo"): string { const date = new Date(iso); if (hasIntl) { try { return new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZone, // may be ignored on some devices }).format(date); } catch { // fall through for devices that throw on timeZone } } return date.toISOString().slice(0, 16).replace("T", " ");}
The trap here is the timeZone option. Even when you pass an arbitrary IANA timezone, some devices display in the device-local zone and ignore your request. Requirements like "show a reservation time fixed to JST from the server" tend to hit this exact pothole in production.
For spots that absolutely must render in a specific zone, do not lean entirely on Intl's timeZone. Either prepare a display-only value with a fixed offset added, or use a focused timezone library for just that path — a two-tier setup that stays safe.
Absorb partial Intl gaps "by design"
So far formatNumber, formatCurrency, and formatDate share the same skeleton: use Intl when it works, fall back when it does not. That uniformity pays off when you add something a little fancier, like relative time.
// lib/format.ts (continued) — relative display like "3 minutes ago"export function formatRelative(iso: string, locale: string = APP_LOCALE): string { const diffSec = Math.round((Date.now() - new Date(iso).getTime()) / 1000); const table: [Intl.RelativeTimeFormatUnit, number][] = [ ["day", 86400], ["hour", 3600], ["minute", 60], ]; if (typeof Intl?.RelativeTimeFormat === "function") { const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); for (const [unit, sec] of table) { if (Math.abs(diffSec) >= sec) return rtf.format(-Math.round(diffSec / sec), unit); } return rtf.format(0, "second"); } // fallback for devices without RelativeTimeFormat if (diffSec < 60) return locale.startsWith("ja") ? "たった今" : "just now"; const mins = Math.round(diffSec / 60); return locale.startsWith("ja") ? `${mins}分前` : `${mins}m ago`;}
Intl.RelativeTimeFormat is relatively new and can be undefined on older devices. Check existence with typeof before using it, and drop to a minimal hand-written version otherwise. Sealing that fallback inside the format layer means your screen code never has to think about engine differences.
Catch cross-locale breakage before shipping
Finally, testing. Because this is a bug you will never find on a single device, write a small test that lays out representative locales and checks the output side by side.
// __tests__/format.test.tsimport { formatCurrency, formatDate } from "../lib/format";const cases = [ { locale: "ja-JP", currency: "JPY", amount: 1480 }, { locale: "en-US", currency: "USD", amount: 1499 }, // minor = cents { locale: "de-DE", currency: "EUR", amount: 1499 },];for (const c of cases) { test(`currency ${c.locale}/${c.currency}`, () => { const out = formatCurrency(c.amount, c.currency, c.locale); // at minimum, guarantee a digit is present and the string is non-empty expect(out).toMatch(/\d/); expect(out.length).toBeGreaterThan(0); });}test("date stays UTC-stable on fallback", () => { const out = formatDate("2026-06-23T02:00:00Z", "ja-JP", "Asia/Tokyo"); expect(out).toMatch(/\d/);});
CI runners and real devices carry different locale data, so a green CI does not guarantee a clean device. That is why, before store submission, I manually switch the device language to each major supported locale and eyeball the price and date screens once. It takes a few minutes and heads off exactly the kind of review I opened with.
Here is a compact table of what to watch per representative locale.
Locale
What to check
Where it tends to break
ja-JP
Grouping comma, "¥" placement
Comma dropped on old Android
en-US
Two decimals, leading "$"
Whether cents were converted
de-DE
Decimal "," and trailing symbol
Reversed symbol position
A next step
Start by running grep for toLocaleString() and hand-rolled grouping inside your own app, and route even one of them through lib/format.ts. The moment the entry point collapses into a single file, locale-driven breakage stops being an "unreproducible bug" and becomes a known issue you fix in one place. I hope it helps in your own work.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.