ReactとIntl.ListFormatで項目を並べる方法

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

Intl.ListFormat を使うと、配列を りんご、みかん、ぶどう のように並べた文字列にできます。英語なら Apple, Orange, and Grape のように、最後の項目の前に「and」が入ります。区切りの記号や「and」「または」のような言葉を、言語ごとに自分で書き分ける必要はありません。

React では、書式をコンポーネントの外で1回だけ作るか、言語が変わるときだけ useMemo で作り直します。項目を太字にするなど、JSX のまま並べたいときは formatToParts() を使います。

補足

検証環境は 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、本番用ビルドでは試していません。

書式を作って format() に配列を渡す

new Intl.ListFormat() に言語と type を渡して書式を作り、format() に文字列の配列を渡します(MDN の Intl.ListFormat() コンストラクターの説明)。type は、「A、B、and C」のように全部を並べる conjunction(既定)、「または」でつなぐ disjunction、単位付きの値をひとまとまりで並べる unit の3つです。

言語が決まっているなら、書式はモジュールの中で作って使い回します。

ts
// 書式はモジュールの中で1回だけ作り、使い回す
const and = new Intl.ListFormat('ja-JP', { type: 'conjunction' });
const or = new Intl.ListFormat('ja-JP', { type: 'disjunction' });

export const formatAnd = (items: string[]) => and.format(items);
export const formatOr = (items: string[]) => or.format(items);

Node.js で結果を出すと、次のとおりでした。

渡した配列 formatAnd formatOr
['りんご', 'みかん', 'ぶどう'] りんご、みかん、ぶどう りんご、みかん、またはぶどう
['りんご', 'みかん'] りんご、みかん りんごまたはみかん
['りんご'] りんご りんご
[] 空の文字列 空の文字列

日本語の conjunction では、2つのときも「と」は入らず、りんご、みかん になりました。「りんごとみかん」と書きたいときは、Intl.ListFormat では作れないので、自分で組み立てます。

type: 'unit' は、「1時間30分」のような量を並べるためのものです。style: 'narrow' を指定すると 1時間30分 とつながり、long と short では 1時間 30分 と空白が入りました。

英語では、言語の指定で「,」の有無まで変わる

同じ配列でも、言語によって区切りが変わります。['Apple', 'Orange', 'Grape'] を渡した結果です。

言語 conjunction disjunction
en-US Apple, Orange, and Grape Apple, Orange, or Grape
en-GB Apple, Orange and Grape Apple, Orange or Grape
de-DE Apple, Orange und Grape Apple, Orange oder Grape

en-US では「and」の前にもカンマが入り、en-GB では入りません。MDN のコンストラクターの説明にある、Oxford comma の例と同じ結果です。en-US で2つのときは Apple and Orange でした。

style を変えると、en-US の conjunction は short で Apple, Orange, & Grape、narrow で Apple, Orange, Grape になりました。ja-JP の conjunction は、3つの style のどれでも りんご、みかん、ぶどう でした。

言語を切り替えるなら useMemo で作り直す

画面で言語を切り替える場合は、useMemo で書式をキャッシュし、言語か type が変わったときだけ作り直します(公式リファレンスの useMemo の説明)。

ts
import { useMemo } from 'react';

// 言語や種類が変わったときだけ、書式を作り直す
export function useListFormat(
  locale: string,
  type: Intl.ListFormatType = 'conjunction',
): Intl.ListFormat {
  return useMemo(() => new Intl.ListFormat(locale, { type }), [locale, type]);
}

検証では、作った回数を数える処理を足したこのフックを、conjunction と disjunction の2つで呼ぶ画面を作り、チェックボックスで選んだ果物を並べました。書式を作った回数を数えたところ、最初の表示で2回でした。ボタンで3回再描画しても、チェックボックスを切り替えても2回のままでした。言語を英語に切り替えると4回、日本語に戻すと6回になり、言語が変わるたびに2つとも作り直されていました。

Chromiumで開いた検証用の画面。チェックした3つの果物を conjunction、disjunction、太字で並べ、その下に5人の名前を「佐藤、鈴木、他3人」とまとめた表示がある
チェックした果物を Intl.ListFormat で並べた画面(幅375px)

useMemo は性能のための仕組みで、React がキャッシュを捨てて作り直すこともある、と公式リファレンスに書かれています。書式は作り直しても結果が変わらないので、問題にはなりません。

0件のときは空の文字列になる

チェックをすべて外すと、format() は空の文字列を返し、画面のその部分は空になりました。「選んでいません」のような表示を出したいときは、配列が空かどうかを先に見て分けます。

tsx
<p id="and">{selected.length > 0 ? and.format(selected) : '選んでいません'}</p>

検証の画面では、チェックをすべて外すと、この部分だけに 選んでいません と表示されました。

渡せるのは文字列だけ

format() が受け取るのは、文字列を要素に持つ、配列などの iterable です(MDN の format() の説明)。Node.js で数の配列 [1, 2] を渡すと、実行時に TypeError: Iterable yielded 1 which is not a string になりました。TypeScript では、次のように型チェックの時点でエラーになります。

text
typecheck/numbers.ts(2,10): error TS2345: Argument of type 'number[]' is not assignable to parameter of type 'Iterable<string>'.

数を並べたいときは、[1, 2].map(String) のように文字列にしてから渡します。結果は 1、2 でした。

一方で、文字列そのものも文字の iterable なので、型エラーになりません。配列のつもりで 'abc' を渡すと、1文字ずつに分かれて a、b、c になりました。

なお、tsconfig.json の lib が es2020 のままだと、Intl.ListFormat が型に見つからず、Property 'ListFormat' does not exist on type 'typeof Intl'. ... Try changing the 'lib' compiler option to 'es2021' or later. というエラーになりました。lib を es2021 以降にします。検証では es2022 にしています。

太字やリンクを並べるには formatToParts を使う

format() は文字列を返すので、項目を <strong> やリンクにしたいときには使えません。formatToParts() を使うと、結果が項目(element)と区切り(literal)に分かれた配列で返ります(MDN の formatToParts() の説明)。['A', 'B', 'C'] では、ja-JP は 、 が、en-US は , と , and が literal として返りました。

そこで、書式には項目の代わりに番号の文字列を渡し、element の部分を元の JSX に戻します。

tsx
import type { ReactNode } from 'react';

type Props = {
  items: ReactNode[];
  format: Intl.ListFormat;
};

// 要素を JSX のまま並べ、区切りの「、」や「and」だけを書式に任せる
export function FormattedList({ items, format }: Props) {
  // 書式には、要素の代わりに番号の文字列を渡す
  const parts = format.formatToParts(items.map((_, i) => String(i)));
  return (
    <>
      {parts.map((part, i) =>
        part.type === 'element' ? (
          <span key={i}>{items[Number(part.value)]}</span>
        ) : (
          <span key={i}>{part.value}</span>
        ),
      )}
    </>
  );
}

使うときは、items に JSX の配列を渡します。

tsx
<FormattedList items={selected.map((f) => <strong key={f}>{f}</strong>)} format={and} />

Chromium で描かれた HTML は次のとおりで、区切りの 、 だけが太字の外に出ていました。

html
<span><strong>りんご</strong></span><span>、</span><span><strong>みかん</strong></span><span>、</span><span><strong>ぶどう</strong></span>

英語に切り替えると、同じコンポーネントで Apple, Orange, and Grape と表示されました。区切りを type で見分けているので、言語ごとに分ける必要はありません。

3人目からを「他N人」にまとめる

人数が多いときに先頭の数人だけ出すなら、残りの人数を最後の項目として足してから並べます。

ts
// 先頭の max 件だけ並べ、残りは「他N人」にまとめる
export function formatNames(names: string[], max: number): string {
  if (names.length <= max) return and.format(names);
  const rest = names.length - max;
  return and.format([...names.slice(0, max), `他${rest}人`]);
}

5人の名前で max を2にすると 佐藤、鈴木、他3人、2人なら 佐藤、鈴木 でした。「他N人」の言い方は Intl.ListFormat が作るものではないので、英語にも対応するなら、その部分は言語ごとに文を用意します。

サーバーとブラウザで結果をそろえる

言語を省くと、実行している環境の既定の言語が使われます(MDN の locales 引数の説明)。検証環境では、new Intl.ListFormat() の言語が Node.js では en-US、日本語に設定した Chromium では ja-JP で、既定の言語がそろっていませんでした。

言語を 'ja-JP' と指定したこの記事のコードでは、Node.js の renderToString で描いた HTML と、Chromium で描いた HTML を、一覧を表示している4か所(conjunction、disjunction、太字、「他N人」)で比べたところ、すべて同じでした。サーバーで描く画面でも、言語は省かずに指定しておきます。

ただし、MDN の format() の説明には、出力は同じ言語でも実装によって違うことがあり、決まった文字列と比べないように、と書かれています。

​Posted by 86