Reactで「1,000~2,000円」のような範囲を表示する方法

​​React​Intl.NumberFormat,TypeScript,useMemo

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() の説明)。

ts
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() の説明)。

text
約(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万円 は、次の部品に分かれました。

text
1(integer, startRange) 万(compact, startRange) " ~ "(literal, shared) 2(integer, endRange) 万(compact, endRange) 円(currency, shared)

この区切りの部品の前後の空白を取ってつなげると、1万~2万円、1.2万~1.5万円 になりました。

片方だけの指定と、同じ値の扱いをまとめた関数

ここまでの対処を、1つの関数にまとめます。

ts
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 の説明)。

tsx
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で開いた検証用の画面。下限 10000、上限 20000 の入力欄の下に、PriceRange は「10,000~20,000円」、PriceRange compact は「1万~2万円」、formatRange(そのまま)は「10,000~20,000円」、formatRange(円記号)は「¥10,000 ~ ¥20,000」と並び、その下に再描画のボタンがある
下限 10,000、上限 20,000 を入力した画面(画面の幅375px)

入力欄に値を入れたときの、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 の型がなく、次のエラーになりました。

text
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() を呼んだ結果がすべて同じでした。

​Posted by 86