How to format money in React
Currency formatting looks like a solved problem until the first JPY amount, the first negative, and the first value you do not have. This is the whole method, in the order the bugs arrive.
The short version
new Intl.NumberFormat(locale, {
style: "currency",
currency,
currencyDisplay: "narrowSymbol",
currencySign: "accounting",
}).format(amount / 10 ** scale);
Four decisions are already made for you by those options, and each one is a bug you now do not have. The rest of this page is what the snippet does not cover.
1. Do not store money in a float
0.1 + 0.2 === 0.30000000000000004
In a ledger that is a reconciliation which never balances and a support ticket nobody can reproduce. Store an integer of minor units, with the currency travelling alongside 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 is not decoration. An amount without one is a number that renders differently in two places and cannot be summed with anything. The full argument is in numerics.
Components never do arithmetic. They format and lay out. Pass totals in, 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 in every one of those cases except the second.
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 are two different questions and they get two different 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 is right. Deriving the locale from the currency — a common shortcut — gives that same accountant American separators in a German interface.
4. Negatives are parenthesised, 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 rather than in a scanned column.
The alignment problem this creates has its own page: negative numbers in a financial table.
5. null is not 0
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 is zero |
| Unknown | n/a, tertiary |
We do not 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 is forbidden anywhere a person 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 is enforced inside one primitive rather than repeated at every call site. Install it, and the rest of the system inherits the behaviour:
npx shadcn@latest add @lopifi/theme @lopifi/money
Money— the primitive, with its four-layer spec and full sourcePercent— because percent and percentage points are different numbersDelta— a signed change, with polarity stated rather than 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 cheque, the note and the contract schedule