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を検索し、対応する翻訳された文字列をレンダリングします。ロケールが変更され、ローダーが異なるメッセージで再実行されると、すべてのコンポーネントはコード変更なしで自動的に新しい翻訳を表示します。