React Router v7でファイルから翻訳をロードする方法

翻訳可能なコンテンツをコードから分離する

問題点

ユーザー向けの文字列をコンポーネントに直接ハードコーディングすると、コンテンツとコードの間に密結合が生じます。新しい言語を追加するたびに、開発者は実装ファイルを修正し、条件分岐ロジックを拡張してコードの複雑性を増大させる必要があります。文言が変更される場合、軽微な表現の調整であってもコードのデプロイが必要になります。このアプローチでは、翻訳ワークフローがエンジニアリングサイクルに依存し、非技術系チームメンバーが独自にコンテンツを管理することができなくなります。

アプリケーションが成長するにつれて、散在する文字列リテラルの追跡と保守が困難になります。コードベース全体で特定のフレーズのすべての出現箇所を見つけることはエラーが発生しやすく、一元化された信頼できる情報源がなければ、類似したメッセージ間の一貫性を確保することはほぼ不可能になります。

解決策

すべての翻訳可能な文字列を、言語ごとに整理された外部JSONファイルに抽出し、ロケールごとに1つのファイルを作成します。コンポーネント内のハードコードされた文字列を、これらのファイル内のエントリを参照するメッセージ識別子に置き換えます。実行時に、アプリケーションはユーザーのロケールに基づいて適切な翻訳ファイルをロードし、それらのメッセージを国際化ライブラリに提供します。ライブラリは識別子を翻訳された値に解決します。

この分離により、翻訳者はコードに触れることなく直接JSONファイルを操作でき、単純なファイル変更によるコンテンツ更新が可能になり、各言語の文字列に対する単一の信頼できる情報源が提供されます。

手順

1. 各ロケール用の翻訳ファイルを作成する

専用ディレクトリに翻訳ファイルを整理し、言語ごとに1つのJSONファイルを作成します。各ファイルは、メッセージ識別子から翻訳された文字列へのマッピングをフラットなオブジェクトとして構造化します。

{
"welcome.title": "Welcome back",
"welcome.subtitle": "Continue where you left off",
"nav.home": "Home",
"nav.about": "About",
"nav.contact": "Contact"
}

これを英語用に app/translations/en.json として保存し、次に app/translations/es.jsonapp/translations/fr.json のような他の言語用の並列ファイルを作成します。すべてのファイルで一貫したキーを使用することで、同じ識別子が各ロケールで適切な翻訳に解決されるようにします。

2. ルートローダーで翻訳を読み込む

ルートローダーを使用して、レンダリング前に現在のロケールの翻訳ファイルを取得します。これにより、コンポーネントがマウントされる際にメッセージが利用可能になります。

import type { Route } from "./+types/root";
import enMessages from "./translations/en.json";
import esMessages from "./translations/es.json";
import frMessages from "./translations/fr.json";

const messages: Record<string, Record<string, string>> = {
  en: enMessages,
  es: esMessages,
  fr: frMessages,
};

export async function loader({ request }: Route.LoaderArgs) {
  const url = new URL(request.url);
  const locale = url.searchParams.get("locale") || "en";

  return {
    locale,
    messages: messages[locale] || messages.en,
  };
}

ローダーはURL クエリパラメータからロケールを読み取り、ロケールとそれに対応するメッセージの両方を返します。コンポーネントはloaderDataを通じてこのデータにアクセスし、国際化プロバイダーを設定できます。

3. 読み込んだメッセージでIntlProviderを設定する

react-intlのIntlProviderでアプリケーションをラップし、ローダーデータからロケールとメッセージを渡します。

import { IntlProvider } from "react-intl";
import { Outlet } from "react-router";
import type { Route } from "./+types/root";

export default function Root({ loaderData }: Route.ComponentProps) {
  return (
    <IntlProvider locale={loaderData.locale} messages={loaderData.messages}>
      <html lang={loaderData.locale}>
        <head>
          <meta charSet="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
        </head>
        <body>
          <Outlet />
        </body>
      </html>
    </IntlProvider>
  );
}

IntlProviderは、Reactコンテキストを通じてロケールとメッセージをすべての子孫コンポーネントで利用できるようにします。子ルートはOutletを通じてレンダリングされ、翻訳データへのアクセスを継承します。

4. コンポーネント内で識別子によるメッセージの参照

ハードコードされた文字列を、翻訳ファイルからメッセージ識別子を参照するFormattedMessageコンポーネントに置き換えます。

import { FormattedMessage } from "react-intl";

export default function Welcome() {
  return (
    <div>
      <h1>
        <FormattedMessage id="welcome.title" />
      </h1>
      <p>
        <FormattedMessage id="welcome.subtitle" />
      </p>
      <nav>
        <a href="/">
          <FormattedMessage id="nav.home" />
        </a>
        <a href="/about">
          <FormattedMessage id="nav.about" />
        </a>
        <a href="/contact">
          <FormattedMessage id="nav.contact" />
        </a>
      </nav>
    </div>
  );
}

FormattedMessageコンポーネントは、IntlProviderによって提供されるメッセージオブジェクト内でidを検索し、対応する翻訳された文字列をレンダリングします。ロケールが変更され、ローダーが異なるメッセージで再実行されると、すべてのコンポーネントはコード変更なしに自動的に新しい翻訳を表示します。