Reactで件数に合わせて英語の複数形や1st・2ndを付ける方法

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

Intl.PluralRules を使うと、件数に合わせて 1 item と 2 items を出し分けられます。type: 'ordinal' を指定すれば、1st・2nd・3rd・11th・21st のような順番の接尾辞も、11 から 13 の例外を自分で書かずに選べます。

このオブジェクトは英単語そのものは返しません。select() が返すのは one や other のような区分の名前だけです。区分ごとの言葉は自分で用意します。React では、区分と言葉の対応表を props で渡すコンポーネントにすると扱いやすくなります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4(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、本番用ビルド、2つの数の範囲に使う selectRange() は試していません。

select() は区分の名前を返す

new Intl.PluralRules('en-US') で作ったオブジェクトの select() に数を渡すと、英語では one か other が返ります(MDN の Intl.PluralRules の説明)。区分の名前は zero、one、two、few、many、other の6つで、言語によって使う区分が違います。

返った区分を見て、単数形と複数形を選びます。

ts
// 英語の書式は、モジュールの中で1回だけ作って使い回す
const cardinal = new Intl.PluralRules('en-US');

// 単数形・複数形の言葉を、件数に合わせて選ぶ
export function itemLabel(count: number): string {
  return cardinal.select(count) === 'one' ? `${count} item` : `${count} items`;
}

Node.js で itemLabel() に数を渡した結果です。

渡した数 結果
0 0 items
1 1 item
2 2 items
1.5 1.5 items
-1 -1 item

英語では 0 も複数形になり、-1 は単数形になりました。

「1st・2nd・3rd」は type: 'ordinal' で選ぶ

順番を表す数には、type: 'ordinal' を指定します。英語の ordinal では、select() が one(st)、two(nd)、few(rd)、other(th)の4つを返します(MDN の Intl.PluralRules の説明)。区分ごとの接尾辞を表にしておき、数の後ろに付けます。

ts
const ordinal = new Intl.PluralRules('en-US', { type: 'ordinal' });

// 順番の接尾辞。英語の ordinal で返るのは one・two・few・other の4つ
const suffixes: Record<Intl.LDMLPluralRule, string> = {
  one: 'st',
  two: 'nd',
  few: 'rd',
  other: 'th',
  zero: 'th',
  many: 'th',
};

export function toOrdinal(n: number): string {
  return `${n}${suffixes[ordinal.select(n)]}`;
}

Record<Intl.LDMLPluralRule, string> にしているので、英語では返らない zero と many も書いておかないと型エラーになります。どちらも th にしておけば、表に抜けがなくなります。

1から25までを toOrdinal() に通すと、11th・12th・13th と 21st・22nd・23rd が正しく分かれました。

text
1st 2nd 3rd 4th 5th 6th 7th 8th 9th 10th 11th 12th 13th 14th 15th 16th 17th 18th 19th 20th 21st 22nd 23rd 24th 25th

100台でも 101st・102nd・103rd と 111th・112th・113th になり、0 は 0th でした。この区分は、Unicode の CLDR にある英語の順序数の規則(10で割った余りと100で割った余りで決める)のとおりです。

件数と言葉の対応表を渡すコンポーネントにする

画面で言語を切り替えるなら、言語ごとに区分と言葉の対応表を用意し、コンポーネントに渡します。other は、CLDR の件数の規則でも順番の規則でも、すべての言語にある区分です。そこで、型で必ず書かせます。

tsx
import { usePluralRules } from './usePluralRules';

// other だけは必ず書かせ、ほかの区分は言語に合わせて足す
export type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & {
  other: string;
};

type Props = {
  count: number;
  locale: string;
  forms: PluralForms;
};

// forms の中の「#」を、言語に合わせて区切った数に置き換える
export function Plural({ count, locale, forms }: Props) {
  const rules = usePluralRules(locale);
  const text = forms[rules.select(count)] ?? forms.other;
  return <>{text.replace('#', count.toLocaleString(locale))}</>;
}

other を書き忘れると、型チェックで次のエラーになりました。

text
typecheck/forms.ts(4,14): error TS2322: Type '{ one: string; }' is not assignable to type 'PluralForms'.
  Property 'other' is missing in type '{ one: string; }' but required in type '{ other: string; }'.

usePluralRules では useMemo を使い、言語か種類が変わったときだけ PluralRules を作り直します(公式リファレンスの useMemo の説明)。

ts
import { useMemo } from 'react';

// 言語か種類が変わったときだけ、PluralRules を作り直す
export function usePluralRules(
  locale: string,
  type: Intl.PluralRuleType = 'cardinal',
): Intl.PluralRules {
  return useMemo(() => new Intl.PluralRules(locale, { type }), [locale, type]);
}

使う側では、言語ごとの対応表を渡します。日本語の区分は other だけなので、1つ書けば足ります。

tsx
const cartForms: Record<string, PluralForms> = {
  'en-US': { one: '# item in your cart', other: '# items in your cart' },
  'ja-JP': { other: 'カートに # 個の商品' },
};

<Plural count={count} locale={locale} forms={cartForms[locale]} />

検証では、件数を増減するボタンと、言語を切り替えるボタンを置いた画面を作りました。順位の列には toOrdinal() を使っています。

Chromiumで開いた検証用の画面。上に「1 item in your cart」と表示され、その下に −1、+1、1000、日本語、再描画のボタン、さらに 1st から 5th までの順位と名前の一覧がある
件数が1のときの表示と、toOrdinal で付けた順位(幅375px)

Chromium でボタンを押したときの表示は、次のとおりでした。

操作 表示
最初の表示(1件) 1 item in your cart
1つ減らす(0件) 0 items in your cart
2つ増やす(2件) 2 items in your cart
日本語に切り替える カートに 2 個の商品
1000件にする カートに 1,000 個の商品
英語に戻す 1,000 items in your cart

PluralRules を作った回数を数える処理を足して確かめたところ、最初の表示で1回、再描画を3回しても1回のままでした。日本語に切り替えると2回、英語に戻すと3回になり、言語が変わったときだけ作り直されていました。

日本語は区分が1つ、ロシア語は4つ

resolvedOptions().pluralCategories で、その言語が使う区分の一覧を取れます(MDN の resolvedOptions() の説明)。Node.js で出した結果です。

言語 件数(cardinal) 順番(ordinal)
en-US one, other few, one, two, other
ja-JP other other
fr-FR many, one, other one, other
ru-RU few, many, one, other other
ar-EG few, many, one, two, zero, other other

日本語の select() は、0、1、2 のどれでも other を返しました。ロシア語では 1 と 21 が one、2 と 22 が few、5 と 25 が many でした。フランス語では 0 も one になり、英語とは違います。

英語のように === 'one' で分ける書き方は、ほかの言語ではそのまま使えません。複数の言語に対応するなら、Plural のように区分ごとの対応表を渡す形にし、言語ごとに pluralCategories の区分をすべて埋めます。

小数を「1.0」と表示するなら、桁の指定もそろえる

英語では、1.0 のように小数の桁を付けて表示した数は複数形になります。CLDR の英語の規則では、one は「整数部が1で、小数の桁がない」ときだけです。

Intl.NumberFormat で 1 を 1.0 と表示しても、PluralRules のほうに桁の指定がなければ、select(1) は one を返します。表示と区分がずれて、1.0 star になりました。

ts
const nf = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1 });
const plain = new Intl.PluralRules('en-US');
const same = new Intl.PluralRules('en-US', { minimumFractionDigits: 1 });
const word = (r: Intl.PluralRules, n: number) => (r.select(n) === 'one' ? 'star' : 'stars');

`${nf.format(1)} ${word(plain, 1)}`; // 1.0 star
`${nf.format(1)} ${word(same, 1)}`; // 1.0 stars

Intl.PluralRules は、minimumFractionDigits などの桁の指定を Intl.NumberFormat と同じように受け取ります(MDN の Intl.PluralRules() コンストラクターの説明)。表示に使う桁の指定を PluralRules にも渡すと、1.0 stars になりました。

サーバーで作る画面でも言語を省かない

言語を省いて new Intl.PluralRules() と書くと、実行している環境の既定の言語が使われます(MDN の Intl.PluralRules() コンストラクターの説明)。検証環境での既定の言語は、Node.js では en-US、日本語に設定した Chromium では ja-JP でした。

この記事のコードは言語を指定しているので、Node.js の renderToString でサーバー側に作った HTML と Chromium の最初の表示を比べたところ、件数の文(1 item in your cart)も順位の列も同じでした。サーバーでレンダリングする画面でも、言語は省かずに渡します。

​Posted by 86