Skip to content

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 source
  • Percent — because percent and percentage points are different numbers
  • Delta — 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,56 or 1,234.56- and get signed values back
  • Amount in words — for the cheque, the note and the contract schedule

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.