ReactとIntl.DisplayNamesで国名や言語名を表示する方法

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

Intl.DisplayNames を使うと、US のような国・地域のコードを アメリカ合衆国 に、en-US のような言語のコードを アメリカ英語 に変えられます。名前の一覧を自分で持つ必要はありません。日本語、英語、韓国語など、指定した言語での名前が返ります。

表示の言語が決まっているなら、React のコンポーネントの外で1回だけ作れば足ります。言語を切り替える画面では useMemo を使い、切り替えたときだけ作り直します。この記事では、国名の選択肢と言語の切り替えボタンを作り、Node.js と Chromium で返る名前の違いも比べました。

補足

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

type を決めて書式を作り、of() にコードを渡す

new Intl.DisplayNames() に、表示に使う言語と type を渡して書式を作り、of() にコードを渡します(MDN の Intl.DisplayNames() コンストラクターの説明)。type は省けません。Node.js で省くと TypeError になりました。

ts
// 書式はモジュールの中で1回だけ作り、使い回す
const regionNames = new Intl.DisplayNames('ja', { type: 'region' });
const languageNames = new Intl.DisplayNames('ja', { type: 'language' });

export const regionName = (code: string) => regionNames.of(code);
export const languageName = (code: string) => languageNames.of(code);

Node.js での結果は次のとおりです。国・地域には2文字のコードのほか、419(ラテンアメリカ)のような3桁の地域のコードも渡せます。

渡したコード regionName 渡したコード languageName
JP 日本 ja 日本語
US アメリカ合衆国 en-US アメリカ英語
GB イギリス en-GB イギリス英語
KR 韓国 zh-Hans 簡体中国語
TW 台湾 zh-TW 中国語 (台湾)
419 ラテンアメリカ pt-BR ポルトガル語 (ブラジル)
EU 欧州連合 ain アイヌ語

type には、ほかに文字の種類の script、通貨の currency、暦の calendar、年・月・曜日などの日時の項目名を返す dateTimeField があります。たとえば script の Hira は ひらがな、currency の JPY は 日本円、calendar の japanese は 和暦 でした。

短い名前や「英語 (アメリカ合衆国)」の形にする

国名が長すぎるときは、style: 'short' を指定します。long(既定)と short で比べると、日本語では次のように変わりました。

コード long short
US アメリカ合衆国 アメリカ
GB イギリス 英国
HK 中華人民共和国香港特別行政区 香港
MO 中華人民共和国マカオ特別行政区 マカオ
PS パレスチナ自治区 パレスチナ

narrow は、この5つでは short と同じでした。

言語名は、languageDisplay で形を選べます。既定の dialect は、en-US のような地域の付いた言語に専用の名前があれば、それを使います。standard にすると、言語名のあとに括弧で地域や文字を添えます(MDN のコンストラクターの説明にある languageDisplay の項)。

コード dialect standard
en-US アメリカ英語 英語 (アメリカ合衆国)
zh-Hans 簡体中国語 中国語 (簡体字)
pt-BR ポルトガル語 (ブラジル) ポルトガル語 (ブラジル)

専用の名前がない pt-BR では、どちらも同じでした。standard では、英語 (アメリカ合衆国)、英語 (イギリス) のように、どれも言語名から始まります。

名前のないコードは、そのまま返るか例外になる

of() に名前のないコードを渡したときの結果は、コードの形で2つに分かれます(MDN の of() の説明)。

形は合っていて名前がないだけのコード(国・地域なら英字2文字の AA など)は、既定の fallback: 'code' ではコードがそのまま返り、fallback: 'none' では undefined が返りました。形の合わないコードは、fallback の指定にかかわらず RangeError になります。3文字の USA、1文字の J、空の文字列のほか、言語の en_US(_ で区切ったもの)も RangeError でした。

渡したコード fallback: 'code' fallback: 'none'
JP 日本 日本
AA AA undefined
USA RangeError RangeError

利用者が入力した値や、外部から受け取った値をそのまま渡すなら、例外も undefined にまとめておくと、画面の側で扱いやすくなります。

ts
// 知らないコードは undefined、形の合わないコードも例外にせず undefined にする
const strictRegionNames = new Intl.DisplayNames('ja', { type: 'region', fallback: 'none' });

export function safeRegionName(code: string): string | undefined {
  try {
    return strictRegionNames.of(code);
  } catch {
    return undefined;
  }
}

画面では {safeRegionName('AA') ?? '(名前がありません)'} のように書き、名前がないときの表示を決めます。

なお、小文字の jp を渡すと、名前にならずに jp のまま返りました(fallback: 'none' では undefined)。Chromium でも jp のまま返りました。MDN にも、小文字のコードはどこでも確実に動くとは限らないので大文字で渡すように、と書かれています。

TypeScript では、of() の戻り値の型は string | undefined です。string の変数にそのまま入れると、次のエラーになりました。

text
typecheck/undefined.ts(2,7): error TS2322: Type 'string | undefined' is not assignable to type 'string'.

国の選択肢を、表示の言語で並べる

国・地域のコードの一覧は、Intl からは取れません。Intl.supportedValuesOf() は通貨や暦の一覧を返しますが、'region' を渡すと RangeError: Invalid key : region になりました(MDN の Intl.supportedValuesOf() の説明)。選択肢に出すコードは、自分で配列にして持ちます。

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

ts
import { useMemo } from 'react';

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

選択肢は、表示する名前で並べ替えます。並べ方も表示の言語に合わせるため、Intl.Collator の compare を使います(MDN の Intl.Collator の説明)。

tsx
import { useMemo } from 'react';
import { useDisplayNames } from './useDisplayNames';

// 地域コードの一覧は Intl からは取れないので、自分で用意する
const REGION_CODES = ['JP', 'US', 'GB', 'KR', 'TW', 'CN', 'DE', 'FR', 'BR', 'IN', 'AU', 'CA'];

type Props = {
  locale: string;
  value: string;
  onChange: (code: string) => void;
};

export function CountrySelect({ locale, value, onChange }: Props) {
  const names = useDisplayNames(locale, 'region');
  // 表示する名前で並べ替える。並べ方も表示の言語に合わせる
  const options = useMemo(() => {
    const collator = new Intl.Collator(locale);
    return REGION_CODES.map((code) => ({ code, name: names.of(code) ?? code })).sort((a, b) =>
      collator.compare(a.name, b.name),
    );
  }, [names, locale]);

  return (
    <select id="country" value={value} onChange={(e) => onChange(e.target.value)}>
      {options.map(({ code, name }) => (
        <option key={code} value={code}>
          {name}
        </option>
      ))}
    </select>
  );
}

value には国のコードを持たせているので、表示の言語を変えても、選んでいる国はそのままです。Chromium で開いた選択肢の順番は、言語ごとに次のようになりました。

text
ja: アメリカ合衆国 / イギリス / インド / オーストラリア / カナダ / ドイツ / ブラジル / フランス / 韓国 / 台湾 / 中国 / 日本
en: Australia / Brazil / Canada / China / France / Germany / India / Japan / South Korea / Taiwan / United Kingdom / United States
ko: 대만 / 대한민국 / 독일 / 미국 / 브라질 / 영국 / 오스트레일리아 / 인도 / 일본 / 중국 / 캐나다 / 프랑스

日本語では、カタカナの国名が五十音順に並び、漢字の国名はそのあとに来ました。漢字の国名は読みの順にはなりません。Node.js で 北朝鮮 を足して並べると、韓国 / 台湾 / 中国 / 日本 / 北朝鮮 の順でした。読みの順に並べたいなら、読みを別に持って、それで並べ替えます。

なお、sort() を比べる関数なしで呼ぶと、文字コードの順になり、フランス が ブラジル より前に来ました。

言語の切り替えボタンは、その言語自身の名前で書く

言語の切り替えボタンは、読めない言語で表示されていても見つけられるよう、それぞれの言語自身の名前で書くのが便利です。書式の言語と、of() に渡すコードを同じにすると、その言語での名前が返ります。

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

const LANGUAGES = ['ja', 'en', 'ko', 'zh-Hans', 'fr'];

type Props = {
  locale: string;
  onChange: (locale: string) => void;
};

// それぞれの言語の名前を、その言語自身で書く(「English」「한국어」など)。
// 表示の言語によらないので、モジュールの中で1回だけ作っておく
const NATIVE_NAMES = Object.fromEntries(
  LANGUAGES.map((code) => [code, new Intl.DisplayNames([code], { type: 'language' }).of(code)]),
);

export function LanguagePicker({ locale, onChange }: Props) {
  // いまの表示の言語での名前も添える
  const names = useDisplayNames(locale, 'language');
  return (
    <ul>
      {LANGUAGES.map((code) => (
        <li key={code}>
          <button type="button" id={'lang-' + code} aria-pressed={code === locale} onClick={() => onChange(code)}>
            {NATIVE_NAMES[code]}
            {code !== locale && <small>({names.of(code)})</small>}
          </button>
        </li>
      ))}
    </ul>
  );
}

NATIVE_NAMES は、ja で 日本語、en で English、ko で 한국어、zh-Hans で 简体中文、fr で français を返しました。括弧の中には、いまの表示の言語での名前が入ります。

Chromiumで開いた検証用の画面。日本語、English(英語)、한국어(韓国語)、简体中文(簡体中国語)、français(フランス語)の5つのボタンと、日本が選ばれた国・地域の選択肢。その下に「JP:日本」と「AA:(名前がありません)」の2行がある
表示の言語が日本語のときの言語ボタンと国の選択肢(幅375px)

useMemo が効いているかは、useDisplayNames の中で new Intl.DisplayNames() を呼んだ回数で確かめました。この2つのコンポーネントを並べた画面では、最初の表示で2回でした。そのあと再描画のボタンを3回押し、国を選び直しても、回数は増えませんでした。表示の言語を英語、韓国語、日本語と順に切り替えると、4回、6回、8回と増えました。切り替えのたびに、国名用と言語名用の2つが作り直されています。

サーバーで描くときは、Node.js とブラウザで名前が違うことがある

同じ言語・同じコードでも、返る名前は実行している環境のデータで決まります。検証環境で、英字2文字のコード AA〜ZZ すべてについて ja の名前を比べたところ、名前が返るコードは Node.js と Chromium でどちらも280個でした。そのうち long では4つの名前が違っていました。

コード Node.js Chromium
HK 中華人民共和国香港特別行政区 香港
MO 中華人民共和国マカオ特別行政区 マカオ
PS パレスチナ自治区 パレスチナ
FK フォークランド諸島 フォークランド諸島 (マルビーナス諸島)

short で違ったのは FK の1つだけでした。

公式リファレンスの hydrateRoot の説明には、サーバーで描いた内容とブラウザでの最初の描画が同じになる必要がある、と書かれています。食い違いはバグとして直すものとされ、開発モードでは React が警告を出します。

この記事の画面は、選択肢に香港などを入れていません。Node.js の renderToString で描いた HTML と Chromium で描いた画面を比べると、ボタン・選択肢・選んだ国の18か所の文字がすべて同じでした。一方、上の表の地域を選択肢に入れてサーバーで描くと、サーバー(Node.js)で描いた名前と、ブラウザ(Chromium)で描いた名前が食い違います。

また、表示の言語を省くと、実行している環境の既定の言語が使われます。検証環境では、Node.js が en-US、日本語に設定した Chromium が ja-JP でした。サーバーで描く画面では、言語は省かずに指定します。

TypeScript では lib を es2020 以降にする

Intl.DisplayNames の型は、tsconfig.json の lib が es2020 以降で使えます。lib を es2019 にすると、次のエラーになりました。

text
src/displayNames.ts(2,30): error TS2550: Property 'DisplayNames' does not exist on type 'typeof Intl'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2020' or later.

ただし、@types/node を読み込んでいると、lib が es2019 でもエラーになりませんでした。@types/node の index.d.ts に、es2020 の型を読み込む /// <reference lib="es2020" /> の行があるためです。

​Posted by 86