TanStack Start v1でファイルから翻訳を読み込む方法

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

問題点

ユーザー向けの文字列をコンポーネントに直接ハードコーディングすると、コンテンツとコードの間に強い結合が生じます。文字列が変更されたり、新しい言語が追加されるたびに、開発者はソースファイルを特定して修正し、アプリケーションを再デプロイする必要があります。このアプローチでは、翻訳ワークフローがエンジニアリングサイクルに依存し、技術的知識のないチームメンバーがコピーを更新できなくなります。サポートする言語の数が増えるにつれて、適切な文字列を選択するための条件付きロジックは扱いにくく、エラーが発生しやすくなります。その結果、イテレーションが遅くなり、メンテナンスコストが高くなり、本来は別の場所に存在すべき翻訳可能なコンテンツでコードベースが煩雑になります。

解決策

すべての翻訳可能な文字列をアプリケーションコードから分離し、ロケールごとに1つの外部JSONファイルに保存します。各メッセージに安定したキーを定義し、リテラルテキストの代わりにコンポーネント内でそれらのキーを参照します。実行時に、ユーザーのロケールに基づいて適切な翻訳ファイルをロードし、それらのメッセージをreact-intlのIntlProviderに提供します。これによりコンテンツとコードが分離されます:翻訳者はJSONファイルを直接操作でき、コピーの変更にはコードの修正が不要であり、新しい言語を追加するには、コンポーネントに触れることなく新しいファイルを追加するだけで済みます。

ステップ

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

専用ディレクトリにJSONファイルとして翻訳を整理し、ロケールごとに1つのファイルにすべてのメッセージのキーと値のペアを含めます。

{
"welcome": "Welcome back",
"greeting": "Hello, {name}",
"itemCount": "{count, plural, =0 {No items} one {One item} other {# items}}"
}

これをapp/translations/en.jsonとして保存します。app/translations/es.jsonapp/translations/fr.jsonなど、他のロケール用の同様のファイルを作成し、同じキーに翻訳された値を設定します。

2. サーバー関数を使用して翻訳をロードする

サーバー関数を使用して、リクエストされたロケールに基づいてディスクから翻訳ファイルを読み込み、SSR中にサーバーサイドで翻訳がロードされ、ナビゲーション中にクライアントでフェッチされるようにします。

import { createServerFn } from "@tanstack/react-start";
import * as fs from "node:fs";

export const getMessages = createServerFn({ method: "GET" }).handler(
  async ({ request }) => {
    const url = new URL(request.url);
    const locale = url.searchParams.get("locale") || "en";
    const filePath = `app/translations/${locale}.json`;
    const content = await fs.promises.readFile(filePath, "utf-8");
    return JSON.parse(content);
  },
);

この関数は指定されたロケールのJSONファイルを読み込み、解析されたメッセージオブジェクトを返します。サーバー上でのみ実行され、ファイルシステムへのアクセスを安全に保ちます。

3. ユーザーのロケールを決定するヘルパーを作成する

リクエストからロケールを抽出するか、デフォルトにフォールバックする小さなユーティリティを定義し、ルート間で再利用可能にします。

export function getLocaleFromRequest(request: Request): string {
  const url = new URL(request.url);
  const localeParam = url.searchParams.get("locale");
  if (localeParam) return localeParam;
  const acceptLanguage = request.headers.get("accept-language");
  if (acceptLanguage) {
    const match = acceptLanguage.split(",")[0].split("-")[0];
    return match || "en";
  }
  return "en";
}

この関数はまずクエリパラメータをチェックし、次にAccept-Languageヘッダーをチェックし、デフォルトで英語に設定します。ロケール検出のための単一の信頼できるソースを提供します。

4. ルートローダーでメッセージをロードする

ルートローダーを使用して、レンダリング前に現在のロケールのメッセージをフェッチし、コンポーネントツリーで利用できるようにします。

import { createFileRoute } from "@tanstack/react-router";
import { getMessages, getLocaleFromRequest } from "../lib/i18n";

export const Route = createFileRoute("/")({
  loader: async ({ context }) => {
    const locale = getLocaleFromRequest(context.request);
    const messages = await getMessages({ data: { locale } });
    return { locale, messages };
  },
  component: HomePage,
});

function HomePage() {
  const { locale, messages } = Route.useLoaderData();
  return (
    <div>
      <p>{messages.welcome}</p>
    </div>
  );
}

ローダーはサーバー関数を呼び出してメッセージを取得し、コンポーネントはuseLoaderDataを介してそれらにアクセスします。このパターンはSSRとクライアントサイドのナビゲーションの両方で機能します。

5. react-intl にメッセージを提供する

コンポーネントツリーを IntlProvider でラップし、読み込まれたロケールとメッセージを渡すことで、すべての子孫コンポーネントが翻訳にアクセスできるようになります。

import { IntlProvider } from "react-intl";

function HomePage() {
  const { locale, messages } = Route.useLoaderData();
  return (
    <IntlProvider locale={locale} messages={messages}>
      <AppContent />
    </IntlProvider>
  );
}

function AppContent() {
  return (
    <div>
      <FormattedMessage id="welcome" />
    </div>
  );
}

IntlProvider はロケールとメッセージをすべての react-intl コンポーネントとフックで利用できるようにします。これにより、コンポーネントは FormattedMessageuseIntl を使用してキーでメッセージを参照でき、読み込まれたロケールに基づいて正しい翻訳が表示されます。

6. コンポーネント内でキーによるメッセージの参照

react-intl の FormattedMessage コンポーネントまたは useIntl フックを使用して、JSONファイルで定義されたキーを参照して翻訳された文字列を表示します。

import { FormattedMessage, useIntl } from "react-intl";

function UserGreeting({ name }: { name: string }) {
  const intl = useIntl();
  const title = intl.formatMessage({ id: "greeting" }, { name });
  return (
    <div>
      <h1 title={title}>
        <FormattedMessage id="greeting" values={{ name }} />
      </h1>
      <p>
        <FormattedMessage id="itemCount" values={{ count: 5 }} />
      </p>
    </div>
  );
}

FormattedMessage は翻訳された文字列をインラインでレンダリングし、useIntl().formatMessage は属性やJavaScriptロジックで使用するための文字列を返します。どちらも補間のための values を受け入れ、複数形や書式設定のためのICUメッセージ構文をサポートしています。