Dates, Numbers and Plurals
Format money, dates and counts with Intl, polyfill the plural and relative-time APIs Hermes omits, and stop adding an s.
new Intl.NumberFormat(languageTag, { style: "currency", currency: "USD" }).format(value)Numbers, dates and money are not strings you assemble. "1.299,00 €" and "€1,299.00" are the same amount in two locales, and the rules for which is which belong to the engine, not to your code.
Intl, and what Hermes includes
Intl.NumberFormat and Intl.DateTimeFormat each take a BCP 47 language tag and an options object, and return an object with a format method. Hermes, the default engine since React Native 0.70, implements Intl.Collator, Intl.NumberFormat, Intl.DateTimeFormat and Intl.getCanonicalLocales. Its documented support does not include Intl.PluralRules or Intl.RelativeTimeFormat, so install @formatjs/intl-pluralrules and @formatjs/intl-relativetimeformat and import the polyfill plus each locale's data before anything formats.
Plurals are not an "s"
English has two forms. Arabic has six categories — zero, one, two, few, many, other — and Russian three. new Intl.PluralRules(tag).select(count) returns the category a count falls in, and you look up the catalogue entry for it. i18n-js does the same from zero/one/other keys with i18n.t("items", { count }).
What trips people up
Formatters are expensive to construct, so build them once at module scope rather than inside a component body. Pass timeZone when a date must not drift with the device. And options objects use property shorthand: { currency } is exactly { currency: currency }.
Example
// format.ts - no component here, just the formatting rules
import { getLocales } from "expo-localization";
const deviceTag = getLocales()[0].languageTag; // "en-GB", "de-DE", ...
// style: "currency" requires a currency code - it is not optional
export function formatPrice(cents: number, currency: string, tag = deviceTag): string {
return new Intl.NumberFormat(tag, {
style: "currency",
currency, // shorthand for currency: currency
}).format(cents / 100);
}
// timeZone is pinned so the printed day does not move with the device clock
export function formatDay(iso: string, tag = deviceTag): string {
return new Intl.DateTimeFormat(tag, {
dateStyle: "medium",
timeZone: "UTC",
}).format(new Date(iso));
}
export function formatCount(value: number, tag = deviceTag): string {
return new Intl.NumberFormat(tag).format(value);
}
// Output: formatPrice(129900, "USD", "en-US") -> "$1,299.00"
// Output: formatDay("2026-03-14T00:00:00Z", "en-GB") -> "14 Mar 2026"
// Output: formatCount(1299, "en-US") -> "1,299"When to use it
- Show a price with the right symbol, separators and symbol position for the user's locale
- Label a counter so both "1 item" and "5 items" are correct in every language shipped
- Render "yesterday" on a list row instead of a raw timestamp
- Print a date so the day and month appear in the order the reader expects
More examples
Plural categories instead of adding an s
Use this shape for any string containing a count. polyfill-force is the entry point the formatjs docs recommend for React Native, because the feature detection in the plain polyfill entry runs slowly on Android startup. Note that 0 falls in category other in English, not zero: the zero category exists for languages that have a distinct zero form, which is exactly why hand-writing if (count === 1) stops working the moment you add a second language.
// plural.ts - polyfill before anything formats
import "@formatjs/intl-pluralrules/polyfill-force";
import "@formatjs/intl-pluralrules/locale-data/en";
// The six categories CLDR defines. English uses two of them; Arabic uses all six.
type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
type Forms = Partial<Record<PluralCategory, string>>;
const unread: Record<string, Forms> = {
en: { one: "1 unread message", other: "{n} unread messages" },
// ar needs zero, one, two, few, many and other - a translator supplies them
};
export function unreadLabel(count: number, tag: string): string {
const forms = unread[tag.split("-")[0]] ?? unread.en;
const category = new Intl.PluralRules(tag).select(count) as PluralCategory;
const template = forms[category] ?? forms.other ?? "";
return template.replace("{n}", new Intl.NumberFormat(tag).format(count));
}
// Output: unreadLabel(1, "en-US") -> "1 unread message"
// Output: unreadLabel(0, "en-US") -> "0 unread messages"
// Output: unreadLabel(4200, "en-US") -> "4,200 unread messages"Relative time without a clock inside the formatter
This is the timestamp label on a feed row or a chat bubble. The relative-time polyfill builds on Intl.PluralRules and on Intl.NumberFormat.prototype.formatToParts, which the Hermes docs list as Android-only, so if iOS throws, add @formatjs/intl-numberformat and its locale data as well. Keeping the clock outside the function - the caller computes secondsAgo - is what makes the result testable and stops it changing between runs.
// since.ts
import "@formatjs/intl-pluralrules/polyfill-force";
import "@formatjs/intl-pluralrules/locale-data/en";
import "@formatjs/intl-relativetimeformat/polyfill";
import "@formatjs/intl-relativetimeformat/locale-data/en";
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
// secondsAgo is passed in, which keeps this testable and deterministic
export function sinceLabel(secondsAgo: number, tag: string): string {
// numeric: "auto" gives "yesterday" where numeric: "always" gives "1 day ago"
const rtf = new Intl.RelativeTimeFormat(tag, { numeric: "auto" });
if (secondsAgo < HOUR) return rtf.format(-Math.round(secondsAgo / MINUTE), "minute");
if (secondsAgo < DAY) return rtf.format(-Math.round(secondsAgo / HOUR), "hour");
return rtf.format(-Math.round(secondsAgo / DAY), "day");
}
// Output: sinceLabel(90, "en") -> "2 minutes ago"
// Output: sinceLabel(86400, "en") -> "yesterday"Formatters hoisted out of the component
Two rows, each with a product name on the leading edge, a compact view count, and a price on the trailing edge. The lesson is where the formatters are created: at module scope, so a list re-rendering on every scroll frame does not rebuild them. notation: "compact" asks for the locale's short form, "18K" in en-US and different elsewhere, so that is another string not to write by hand - but the Hermes Intl docs list notation: "compact" (along with "engineering", compactDisplay and signDisplay) as unsupported on iOS, where the option is ignored and you get "18,400" instead. Add @formatjs/intl-numberformat and its locale data if the compact form has to be right on both platforms.
import { StyleSheet, Text, View } from "react-native";
import { getLocales } from "expo-localization";
const tag = getLocales()[0].languageTag;
// Built once at module scope. Constructing these per render is measurably slow.
const money = new Intl.NumberFormat(tag, { style: "currency", currency: "USD" });
const compact = new Intl.NumberFormat(tag, { notation: "compact" });
type LineProps = { label: string; cents: number; views: number };
function Line({ label, cents, views }: LineProps) {
return (
<View style={styles.row}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.meta}>{compact.format(views)}</Text>
<Text style={styles.price}>{money.format(cents / 100)}</Text>
</View>
);
}
export default function Basket() {
return (
<View style={styles.screen}>
<Line label="Standing desk" cents={42900} views={18400} />
<Line label="Monitor arm" cents={7950} views={2300} />
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, paddingTop: 48, paddingHorizontal: 16 },
row: { flexDirection: "row", alignItems: "center", paddingVertical: 12 },
label: { flex: 1, fontSize: 16 },
meta: { fontSize: 13, color: "#57606a", marginEnd: 12 },
price: { fontSize: 16, fontWeight: "600" },
});
Discussion