How to format money in React
Currency formatting looks solved until you hit your first JPY amount, your first negative, and your first value you don't have. Here's the whole method, in the order the bugs show up.
The short version#
new Intl.NumberFormat(locale, {
style: "currency",
currency,
currencyDisplay: "narrowSymbol",
currencySign: "accounting",
}).format(amount / 10 ** scale);
Those options make four decisions for you, and each one is a bug you no longer have. The rest of this page covers what the snippet doesn't.
1. Do not store money in a float#
0.1 + 0.2 === 0.30000000000000004
In a ledger, that's a reconciliation that never balances and a support ticket nobody can reproduce. Store an integer of minor units, with the currency next to it:
type Money = {
/** Integer minor units. 1234567 = $12,345.67 */
amount: bigint | number;
/** ISO 4217 */
currency: string;
/** Minor-unit exponent. Defaults from the ISO table. */
scale?: number;
};
The currency code isn't decoration. An amount without one renders differently in two places and can't be summed with anything. The full argument is in numerics.
Components never do arithmetic. They format and lay out. Pass in totals already computed by whoever owns the rounding policy. A component that sums its own rows will eventually disagree with the backend, and the backend is right.
2. Decimal places are per-currency#
JPY has none. USD, EUR and GBP have two. KWD, BHD, JOD and TND have three. Crypto goes to eighteen. toFixed(2) is wrong for all of them except the second group.
const digits = new Intl.NumberFormat(locale, { style: "currency", currency })
.resolvedOptions().maximumFractionDigits;
Let Intl answer for most currencies, backed by the CLDR data your runtime already ships. But CLDR's digits are display precision, and for about seventeen currencies they differ from the ISO 4217 minor unit your bank and processor store amounts in. In V8 (Chrome, Edge, Node), Intl says 0 for HUF, IDR, COP, PKR and IQD; ISO says 2 (3 for IQD). Keep a small override table for those, or HUF 12,345 minor units renders as 12,345 Ft instead of 123.45 Ft.
3. Locale is not currency#
They're separate questions with separate answers. en-US renders a EUR amount as €1,234.56; de-DE renders it 1.234,56 €. Both are correct.
Format in the viewer's locale, in the value's currency.
A German accountant reviewing a US subsidiary sees 1.234,56 $, and that's right. Deriving the locale from the currency, a common shortcut, gives that accountant American separators in a German interface.
4. Negatives are parenthesized, in the right places#
currencySign: "accounting" gives you (1,234.00) natively and locale-correctly. Use it in statements, ledgers and tables. Use an explicit − for deltas and KPIs, where a value stands alone instead of in a column people scan.
The alignment problem this creates has its own page: negative numbers in a financial table.
5. null is not 0#
This is the most common correctness bug in financial UI, and the cheapest to fix:
| State | Renders | Means |
|---|---|---|
| True zero | — in statements, $0.00 in tables and inputs |
We know it, and it's zero |
| Unknown | n/a, tertiary |
We don't have it |
| Redacted | ••••, tertiary |
We have it, you may not see it yet |
| Loading | a structural skeleton | In flight |
value ?? 0 collapses four states into one and prints a number nobody computed. Type the value as Money | null and branch explicitly: three render paths, no fallback.
6. Abbreviate only where nobody relies on it#
$1.2M belongs on a chart axis and a KPI tile. It's forbidden anywhere someone might treat the figure as a record: a statement line, a ledger row, an invoice total, an input. See abbreviation.
What this looks like as a component#
<Money value={{ amount: 124056, currency: "USD" }} /> // $1,240.56
<Money value={{ amount: -124056, currency: "USD" }} accounting /> // ($1,240.56)
<Money value={null} /> // n/a
<Money value={{ amount: 0, currency: "USD" }} statement /> // —
Every rule on this page lives in one primitive instead of being repeated at every call site. Install it and the rest of the system inherits the behavior:
npx shadcn@latest add @lopifi/theme @lopifi/money
Money: the primitive, with its four-layer spec and full sourcePercent: percent and percentage points are different numbersDelta: a signed change, with polarity stated instead of assumed- See them rendering: live, with theme and density switches
- Number cleaner: paste
(1,234.56),1.234,56or1,234.56-and get signed values back - Amount in words: for the check, the note and the contract schedule