---
title: "How to format money in React"
description: "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."
canonical: "https://lopifi.com/docs/guides/format-money-in-react"
---

# 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.

Source: https://lopifi.com/docs/guides/format-money-in-react
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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

```tsx
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

```ts
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:

```ts
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](https://lopifi.com/md/docs/foundations/numerics#1-storage-minor-units-always).

**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.

```ts
const digits = new Intl.NumberFormat(locale, { style: "currency", currency })
  .resolvedOptions().maximumFractionDigits;
```

Let `Intl` answer for most currencies, backed by the [CLDR](https://cldr.unicode.org/) data your runtime already ships. But CLDR's digits are *display* precision, and for about seventeen currencies they differ from the [ISO 4217](https://www.iso.org/iso-4217-currency-codes.html) 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](https://lopifi.com/md/docs/guides/negative-numbers-in-financial-tables).

## 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](https://lopifi.com/md/docs/foundations/numerics#abbreviation).

---

## What this looks like as a component

```tsx
<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:

```bash
npx shadcn@latest add @lopifi/theme @lopifi/money
```

- [`Money`](/components/money): the primitive, with its four-layer spec and full source
- [`Percent`](/components/percent): percent and percentage points are different numbers
- [`Delta`](/components/delta): a signed change, with polarity stated instead of assumed
- [See them rendering](/showcase/primitives): live, with theme and density switches
- [Number cleaner](/tools/clean): paste `(1,234.56)`, `1.234,56` or `1,234.56-` and get signed values back
- [Amount in words](/tools/words): for the check, the note and the contract schedule
