ReactとIntl.NumberFormatで金額や数を表示する方法

​​React​Intl.NumberFormat,Playwright,TypeScript,useMemo

Intl.NumberFormat を使うと、¥1,235 や 1.2万、25.6%、+12 のような表示を、自分で桁区切りや記号を組み立てずに作れます。React では、書式をコンポーネントの外で1回だけ作り、言語や通貨が変わるときだけ useMemo で作り直すのが基本です。

筆者は検証環境で、よく使う指定の結果を Node.js と Chromium で出し、React の画面で書式を作り直す回数を数え、Node.js で書式を毎回作った場合の時間も測りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0(ICU 77.1)です。使ったのは react / react-dom 19.3.1、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。ブラウザは Chrome for Testing 153(Chromium 系)で、Playwright 1.56.1 を使い、言語を日本語に設定して開いています。Firefox と Safari では試していません。

金額は、style と通貨コードを指定して出す

金額を出すときは、style: 'currency' と、currency に ISO 4217 の通貨コードを渡します(MDN の Intl.NumberFormat() コンストラクターの説明)。検証では、書式を1つのファイルにまとめました。

ts
// 書式はモジュールの中で1回だけ作り、使い回す
const yen = new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' });
const compact = new Intl.NumberFormat('ja-JP', { notation: 'compact' });
const percent = new Intl.NumberFormat('ja-JP', { style: 'percent', maximumFractionDigits: 1 });
const diff = new Intl.NumberFormat('ja-JP', { signDisplay: 'exceptZero' });

export const formatYen = (value: number) => yen.format(value);
export const formatCompact = (value: number) => compact.format(value);
export const formatPercent = (ratio: number) => percent.format(ratio);
export const formatDiff = (value: number) => diff.format(value);

1234.5 を渡した結果は次のとおりです。

言語 通貨 結果
ja-JP JPY ¥1,235
en-US JPY ¥1,235
ja-JP USD $1,234.50
de-DE EUR 1.234,50 €

円の書式では小数が出ず、整数に丸められました。通貨の小数の桁数は、既定では、ISO 4217 の一覧にあるその通貨の補助単位の桁数になります(MDN の maximumFractionDigits の説明)。円は補助単位を使わないので0桁です。丸め方の既定は halfExpand で、ちょうど半分のときは0から遠いほうに丸めます(MDN の丸め方の説明)。そのため 1234.5 は ¥1,235、0.5 は ¥1 になりました。

「円」を付けて出したいときは、currencyDisplay: 'name' を指定します。結果は 1,235円 でした。

style: 'currency' で currency を書き忘れると、書式を作った時点で TypeError: Currency code is required with currency style. になりました。TypeScript の型チェックでは、このコードはエラーになりませんでした。

「¥」は全角になるので、文字列で比べない

ja-JP で円を出すと、記号は全角の ¥(U+FFE5)でした。en-US では半角の ¥(U+00A5)です。見た目が似ているため、テストで '¥1' と書いて比べると一致しません。de-DE のユーロでも、金額と € の間は普通の空白ではなく、改行しない空白(U+00A0)でした。

MDN の format() の説明には、出力は実装によって違うことがあり、仕様でも許されているので、決まった文字列と比べないように、と書かれています。

注意

テストで表示を確かめるときは、期待する文字列を手で書かず、同じ書式の format() の結果と比べます。この環境では、ja-JP の結果は手で書いた '¥1' と、de-DE の結果は '1.234,50 €' と一致しませんでした。

大きな数は「万」「億」で短くする

notation: 'compact' を指定すると、ja-JP では「万」「億」を使った短い表記になります。

値 結果
12345 1.2万
1234567 123万
123456789 1.2億

同じ指定でも、en-US では 1234567 が 1.2M になりました。

小数の桁を指定しないと、「小数なし」と「有効数字2桁」のうち、桁の多いほうで丸められます(MDN の、compact のときの桁の既定値)。そのため 1234567 は 123万 で、小数が消えます。maximumFractionDigits: 1 を足すと 123.5万 になりました。

割合は、0から1の値を渡す

style: 'percent' は、渡した値を100倍して % を付けます。0.256 は 26% で、maximumFractionDigits: 1 を付けると 25.6% でした。すでに100倍してある 25.6 を渡すと 2,560% になります。APIから受け取る値が割合なのか百分率なのかを、先に確かめておきます。

小数の丸めは、toFixed() と結果が違うことがありました。1.005 に maximumFractionDigits: 2 を指定すると 1.01 でしたが、(1.005).toFixed(2) は 1.00 でした。1.005 は浮動小数点数では 1.005 より少し小さい値で、toPrecision(20) で出すと 1.0049999999999998934 でした。画面の中で両方を混ぜると、同じ値の表示がそろわなくなります。

増減や単位を付ける

前日比のように増減を出すときは、signDisplay: 'exceptZero' を指定します。12 は +12、-3 は -3、0 は 0 で、0だけ符号が付きません(MDN の signDisplay の説明)。

金額のマイナスは、既定では -¥500 でした。currencySign: 'accounting' を指定すると (¥500) のように括弧で囲まれます。

容量や速さのような単位は、style: 'unit' と unit で付けます。unit: 'megabyte' で 1.5 を渡すと 1.5 MB、unitDisplay: 'long' を足すと 1.5 メガバイト でした。使える単位の名前は、MDN の Intl.supportedValuesOf() の説明に一覧があります。

書式は毎回作らずに使い回す

MDN の toLocaleString() の説明によると、toLocaleString() は呼ぶたびに、言語ごとの大きなデータを探します。同じ指定で何度も呼ぶなら、Intl.NumberFormat を作って format() を使い回すほうがよい、とも書かれています。作った書式は渡された指定を覚えていて、データの一部をキャッシュできるためです。

検証環境の Node.js で、円の書式で10万回 format した時間を5回ずつ測りました。中央値は、毎回 new Intl.NumberFormat した場合が約4,058ミリ秒、毎回 toLocaleString() を呼んだ場合が約3,770ミリ秒、1回作って使い回した場合が約58ミリ秒でした。

書式を毎回作る場合と使い回す場合の、10万回のformatにかかった時間を比べた棒グラフ
円の書式で10万回 format したときの時間(Node.js 22.22.0、5回の中央値)

時間はこの検証用の環境で測った値で、環境によって変わります。毎回作る場合でも、1回あたりにすると約0.04ミリ秒です。数個の数を出すだけなら気にするほどではありませんが、行の多い表を描き直すたびに作ると、その分だけ積み重なります。

言語や通貨が決まっているなら、最初に挙げたファイルのように、モジュールの中で作っておけば十分です。

言語や通貨が変わるなら useMemo で作り直す

利用者の設定で言語や通貨が変わる場合は、useMemo で書式をキャッシュし、言語か通貨が変わったときだけ作り直します(公式リファレンスの useMemo の説明)。

ts
import { useMemo } from 'react';

// 言語や通貨が変わったときだけ、書式を作り直す
export function useCurrencyFormat(locale: string, currency: string): Intl.NumberFormat {
  return useMemo(
    () => new Intl.NumberFormat(locale, { style: 'currency', currency }),
    [locale, currency],
  );
}

検証では、このフックを使うコンポーネントと、描くたびに new Intl.NumberFormat するコンポーネントを同じ画面に置き、書式を作った回数を数えました。ボタンで3回再描画すると、描くたびに作るほうは1回から4回に増え、useMemo のほうは1回のままでした。言語と通貨の切り替えを4回したときは、useMemo のほうも4回作り直し、合計5回になりました。useMemo は性能のための仕組みで、React がキャッシュを捨てて作り直すこともあります。書式は作り直しても結果が変わらないので、問題にはなりません。

記号だけを小さくするには formatToParts を使う

金額の記号だけ小さくしたいときは、文字列を切り分けずに formatToParts() を使います。結果は、{ type, value } の配列で返ります(MDN の formatToParts() の説明)。-1234 を円の書式に渡すと、minusSign、currency、integer、group、integer の5つに分かれました。

tsx
// 通貨の記号だけを小さく表示する
export function Price({ value, format }: { value: number; format: Intl.NumberFormat }) {
  return (
    <span className="price">
      {format.formatToParts(value).map((part, i) =>
        part.type === 'currency' ? (
          <small key={i}>{part.value}</small>
        ) : (
          <span key={i}>{part.value}</span>
        ),
      )}
    </span>
  );
}

type で見分けるので、記号が数の前に来る ja-JP でも、後ろに来る de-DE でも同じコードで済みます。

Chromiumで開いた検証用の画面。商品ごとの価格、販売数、割合、前日比の表と、記号を小さくした金額
Intl.NumberFormat で書式を付けた表と、formatToParts で記号だけ小さくした金額

サーバーとブラウザで結果がそろうか

この記事で挙げた書式の結果や記号の文字コードなど、29行の出力を Node.js 22.22.0 と Chrome for Testing 153 で比べたところ、すべて同じでした。サーバー側で renderToString を使って描いた表の中身も、ブラウザで描いた表と同じでした。どちらも言語を 'ja-JP' のように指定しています。

言語を省くと、実行している環境の既定の言語が使われます(MDN の locales 引数の説明)。サーバーレンダリングでは、サーバーとブラウザの設定の違いが、そのまま表示の違いになります。日付の場合に起きるずれと防ぎ方は、「Reactで日付を表示するとき、サーバーとブラウザのずれを防ぐ方法」の記事で確かめています。数でも、言語は必ず指定しておきます。

ポイント

Intl.NumberFormat には言語を必ず渡し、書式はモジュールの中か useMemo で使い回します。出てきた文字列は手で書いた文字列と比べず、表示の調整は formatToParts() の type で行います。

​Posted by 86