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

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

---

# 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

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

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

```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 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](../foundations/06-numerics.md#1-storage-minor-units-always).

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

```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 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](02-negative-numbers-in-financial-tables.md).

## 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](../foundations/06-numerics.md#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 is enforced inside one primitive rather than repeated at every call site. Install it, and the rest of the system inherits the behaviour:

```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) — because percent and percentage points are different numbers
- [`Delta`](/components/delta) — a signed change, with polarity stated rather than 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 cheque, the note and the contract schedule
