ReactとIntl.ListFormatで項目を並べる方法
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つです。
言語が決まっているなら、書式はモジュールの中で作って使い回します。
// 書式はモジュールの中で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 の説明)。
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つとも作り直されていました。

useMemo は性能のための仕組みで、React がキャッシュを捨てて作り直すこともある、と公式リファレンスに書かれています。書式は作り直しても結果が変わらないので、問題にはなりません。
0件のときは空の文字列になる
チェックをすべて外すと、format() は空の文字列を返し、画面のその部分は空になりました。「選んでいません」のような表示を出したいときは、配列が空かどうかを先に見て分けます。
<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 では、次のように型チェックの時点でエラーになります。
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 に戻します。
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 の配列を渡します。
<FormattedList items={selected.map((f) => <strong key={f}>{f}</strong>)} format={and} />
Chromium で描かれた HTML は次のとおりで、区切りの 、 だけが太字の外に出ていました。
<span><strong>りんご</strong></span><span>、</span><span><strong>みかん</strong></span><span>、</span><span><strong>ぶどう</strong></span>
英語に切り替えると、同じコンポーネントで Apple, Orange, and Grape と表示されました。区切りを type で見分けているので、言語ごとに分ける必要はありません。
3人目からを「他N人」にまとめる
人数が多いときに先頭の数人だけ出すなら、残りの人数を最後の項目として足してから並べます。
// 先頭の 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() の説明には、出力は同じ言語でも実装によって違うことがあり、決まった文字列と比べないように、と書かれています。





