Reactで「1,000~2,000円」のような範囲を表示する方法
Intl.NumberFormat の formatRange() を使うと、下限と上限の2つの数から 1,000~2,000円 のような範囲の文字列を作れます。桁区切りや「円」の位置、範囲の区切りの文字は、言語に合わせて決まります。
ただし、下限と上限が同じだと 約1,000円 になります。片方が NaN や undefined だとエラーになり、null だと 0 として扱われます。そうした場合もまとめて扱う React のコンポーネントを作り、ブラウザで確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4 と 24.22.1(どちらも ICU 78.3)、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、開発用ビルドで動かしました。ブラウザは Playwright で言語を日本語に設定して開いています。Firefox と Safari、本番用ビルド、日本円以外の通貨での PriceRange の表示は試していません。
「円」を後ろに付けるには currencyDisplay: 'name' を指定する
formatRange() は、format() と同じ書式の設定で、2つの数を範囲として整えます(MDN の formatRange() の説明)。
const nf = new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
currencyDisplay: 'name',
});
nf.formatRange(1000, 2000); // "1,000~2,000円"
日本円の書式で currencyDisplay を指定しないと、¥1,000 ~ ¥2,000 になりました。円記号が両方の数に付き、区切りの前後に半角スペースが入ります。currencyDisplay: 'name' は通貨の名前で表示する指定で(MDN の Intl.NumberFormat() コンストラクターの説明)、日本語では「円」が範囲の最後に1つだけ付きました。
ほかの書式でも試した結果です。
| 書式 | 結果 |
|---|---|
ja-JP、数だけ |
1,000~2,000 |
ja-JP、日本円(既定) |
¥1,000 ~ ¥2,000 |
ja-JP、日本円(currencyDisplay: 'name') |
1,000~2,000円 |
ja-JP、日本円(currencyDisplay: 'code') |
JPY 1,000~2,000 |
ja-JP、割合(0.1 と 0.2) |
10% ~ 20% |
ja-JP、キログラム |
3~5 kg |
en-US、米ドル(小数なし) |
$1,000 – $2,000 |
en-US、数だけ |
1,000–2,000 |
同じ日本語でも、書式によって区切りの前後の空白があったりなかったりします。英語では区切りが短いダッシュ(–)になります。
区切りは全角のチルダで、波ダッシュではない
currencyDisplay: 'name' の書式で返る区切りの文字は、全角のチルダ(~、U+FF5E)でした。見た目の似た波ダッシュ(〜、U+301C)ではありません。formatRange(1000, 2000) の結果を、波ダッシュで書いた '1,000〜2,000円' と比べると false、全角のチルダで書いた '1,000~2,000円' と比べると true でした。
既定の書式で付く円記号も全角の ¥(U+FFE5)です。円記号の注意は、別の記事「ReactとIntl.NumberFormatで金額や数を表示する方法」にも書きました。テストで表示を文字列と比べるときは、期待する値も formatRange() で作るか、文字コードを確かめてから書きます。
同じ値や、丸めて同じになる値には「約」が付く
下限と上限が同じだと、formatRange(1000, 1000) は 約1,000円 を返しました。MDN の formatRange() の説明には、2つの値の表示が同じになるときは1つの値だけを出し、言語によっては「ほぼ等しい」ことを示す記号を前に付けると書かれています。2つの値が等しいときにも付きます。
日本円の書式は小数を丸めます(1234.5 は 1,235 になりました)。そのため、999.6 と 1000.4 も、どちらも 1,000 に丸められて 約1,000円 になりました。英語では、maximumFractionDigits: 0 の米ドルで 2.9 と 3.1 を渡すと ~$3 でした。
formatRangeToParts() で部品に分けると、「約」は type が approximatelySign の部品として返ります(MDN の formatRangeToParts() の説明)。
約(approximatelySign) 1(integer) ,(group) 000(integer) 円(currency)
価格の絞り込みで「1,000円から1,000円まで」を選んだときに「約1,000円」と出すと、おおよその値段のように読めます。この記事のコンポーネントでは、format() で2つの値を整えて比べ、同じなら format() の結果を1つだけ出すことにしました。
逆の順番でも、エラーにならずにそのまま出る
formatRange(2000, 1000) は、エラーにならずに 2,000~1,000円 を返しました。値を入れ替えたりはしません。下限と上限を利用者が入力する画面では、小さいほうを先にしてから渡します。
一方、片方が NaN だと RangeError、undefined だと TypeError になりました。MDN の formatRange() の説明でも、この2つの例外が挙げられています。
null は例外にならず、0 として扱われました。formatRange(null, 2000) は 0~2,000円、formatRange(1000, null) は 1,000~0円 です。入力欄の空の文字列を Number('') で変換した値も 0 なので、0~2,000円 になりました。未入力を null で表すときは、渡す前に分けて扱います。
ほかに、'1000' のような文字列と、1000n のような BigInt も受け取れました。どちらも 1,000~2,000円 になります。
「万」で短くすると、区切りの空白が変わる
notation: 'compact' を付けると、1万 のように短くした範囲になります。ただし、区切りの前後の空白は、値によって付いたり付かなかったりしました。
| 下限と上限 | formatRange() の結果 |
|---|---|
| 10,000 と 20,000 | 1万 ~ 2万円 |
| 12,000 と 15,000 | 1.2万 ~ 1.5万円 |
| 5,000 と 20,000 | 5000~2万円 |
短くした書式では、1万未満の 5000 に桁区切りが付きません。下限と上限を変えて同じ場所に表示すると、空白の有無と合わせて、表示の形が値ごとに変わります。
そろえるには、formatRangeToParts() で部品に分け、区切りの部品から空白を取ります。区切りは type が literal で、source が shared の部品です。source は、その部品が下限(startRange)、上限(endRange)、両方に共通(shared)のどれに属するかを表します(MDN の formatRangeToParts() の説明)。1万 ~ 2万円 は、次の部品に分かれました。
1(integer, startRange) 万(compact, startRange) " ~ "(literal, shared) 2(integer, endRange) 万(compact, endRange) 円(currency, shared)
この区切りの部品の前後の空白を取ってつなげると、1万~2万円、1.2万~1.5万円 になりました。
片方だけの指定と、同じ値の扱いをまとめた関数
ここまでの対処を、1つの関数にまとめます。
export type PriceRangeValue = { min: number | null; max: number | null };
// 入力欄が空のときの NaN も「指定なし」として扱う
const toValue = (n: number | null) => (n !== null && Number.isFinite(n) ? n : null);
// 下限・上限の片方だけ、同じ値、逆の順番のときもそろえて文字列にする
export function formatPriceRange(nf: Intl.NumberFormat, range: PriceRangeValue): string {
let min = toValue(range.min);
let max = toValue(range.max);
if (min === null && max === null) return '';
if (max === null) return `${nf.format(min!)}~`;
if (min === null) return `~${nf.format(max)}`;
// formatRange は逆の順番でもエラーにならず、そのまま「2,000~1,000円」になる
if (min > max) [min, max] = [max, min];
// 表示が同じになるときは「約1,000円」になるので、format で1つだけ出す
if (nf.format(min) === nf.format(max)) return nf.format(min);
return joinTight(nf.formatRangeToParts(min, max));
}
// 「1万 ~ 2万円」の「~」の前後の空白を取り、「1万~2万円」にそろえる
export function joinTight(parts: Intl.NumberRangeFormatPart[]): string {
return parts
.map((p) => (p.type === 'literal' && p.source === 'shared' ? p.value.trim() : p.value))
.join('');
}
片方だけ指定されたときの 1,000円~ と ~2,000円 は、formatRange() では作れないので、format() の結果に全角のチルダをつなげています。区切りの空白を取る処理も含めて、日本語の表示のための関数です。英語の米ドル($1,000 – $2,000)のように、区切りの前後に空白が入る書式には使いません。
Infinity を渡すと 1,000~∞円 になったので、Number.isFinite() で Infinity も「指定なし」に含めています。
React のコンポーネントにする
Intl.NumberFormat は、書式の設定が変わらないかぎり使い回せます。useMemo で、compact が変わらないかぎり同じものを使い回します(公式リファレンスの useMemo の説明)。
import { useMemo } from 'react';
import { formatPriceRange } from './formatPriceRange';
type Props = {
min: number | null;
max: number | null;
compact?: boolean; // 「1万~2万円」のように短くするとき true
};
export function PriceRange({ min, max, compact = false }: Props) {
// compact が変わったときだけ作り直す
const nf = useMemo(
() =>
new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
currencyDisplay: 'name', // 「¥1,000」ではなく「1,000円」にする
notation: compact ? 'compact' : 'standard',
}),
[compact],
);
return <span className="price-range">{formatPriceRange(nf, { min, max })}</span>;
}
使う側では、<PriceRange min={1000} max={2000} /> のように書きます。
検証では、下限と上限の入力欄を置き、その値を PriceRange と、formatRange() をそのまま呼んだ結果に渡す画面を作りました。入力欄が空のときは、PriceRange には null を、formatRange() には NaN を渡しています。

入力欄に値を入れたときの、Chromium での表示は次のとおりでした。
| 下限 | 上限 | PriceRange |
compact 付き |
formatRange() をそのまま |
|---|---|---|---|---|
| 1000 | 2000 | 1,000~2,000円 |
1000~2000円 |
1,000~2,000円 |
| 1000 | 1000 | 1,000円 |
1000円 |
約1,000円 |
| 999.6 | 1000.4 | 1,000円 |
1000円 |
約1,000円 |
| 1000 | 500 | 500~1,000円 |
500~1000円 |
1,000~500円 |
| 1000 | (空) | 1,000円~ |
1000円~ |
RangeError |
| (空) | 2000 | ~2,000円 |
~2000円 |
RangeError |
| 12000 | 15000 | 12,000~15,000円 |
1.2万~1.5万円 |
12,000~15,000円 |
Intl.NumberFormat を作った回数を数える処理を足して確かめると、最初の表示では、2つの PriceRange で合わせて2回でした。再描画を3回しても、入力欄の値を変えても、2回のままでした。
renderToString でサーバー側の HTML も作りました。下限 1000・上限 2000 の表示は、Node.js 22 と 24 のどちらでも、ブラウザと同じ 1,000~2,000円 と ¥1,000 ~ ¥2,000 でした。
TypeScript では lib に es2023 以降を入れる
tsconfig.json の lib が es2022 のままだと、formatRange の型がなく、次のエラーになりました。
typecheck/old-lib.ts(2,29): error TS2339: Property 'formatRange' does not exist on type 'NumberFormat'.
lib を ["es2023", "dom"] にすると、formatRange と formatRangeToParts、部品の型の Intl.NumberRangeFormatPart が使えるようになり、型チェックが通りました。
MDN の formatRange() の互換性の表では、対応した最初の版は Chrome 106、Firefox 116、Safari 15.4、Node.js 19.0.0 です。検証環境の Node.js 22 と 24 では、formatRange() と formatPriceRange() を呼んだ結果がすべて同じでした。





