チュートリアル//15 読了時間

ExpoによるReact Nativeのローカライゼーション — 50以上の言語でアプリをリリースする

Eray Gündoğmuş
共有

モバイルアプリはユーザーのデバイス上で動作し、あなたが管理するサーバー上では動作しません。この違いにより、React Nativeにおけるローカライゼーションはウェブのローカライゼーションと根本的に異なります。オフラインアクセス、デバイスのロケール変更、複数言語のアプリストアメタデータ、OTA翻訳アップデートを処理する必要があり、それでいてバンドルサイズを合理的に保たなければなりません。

このガイドでは、デバイスのロケール検出から本番環境に対応したOTA翻訳デリバリーまで、ExpoでReact Nativeアプリをローカライズする方法を解説します。デバイス設定には expo-localization、翻訳ランタイムには i18next、翻訳デリバリーにはBetter i18nのCDNを使用します。ウェブフロントエンドも構築しているチームには、2026年向けNext.js i18n完全ガイドで同等のウェブ側スタックを解説しています。

モバイルi18nがウェブと異なる理由

コードに入る前に、モバイルのローカライゼーションが固有の課題を抱えている理由を理解しましょう。

1. オフラインファースト

ウェブアプリはリクエストのたびに翻訳を取得できます。モバイルアプリはネットワークの利用可能性を前提にできません。翻訳はフォールバックとしてアプリにバンドルされている必要があり、接続が可能なときにOTAアップデートが最新のコンテンツを取得します。

2. デバイスロケール

ウェブでは、URL、Cookie、または Accept-Language ヘッダーからロケールを検出します。モバイルでは、デバイスにユーザーが電話の設定で設定するシステムロケールがあります。アプリはデフォルトでこの設定を尊重し、オプションでアプリ内言語ピッカーを提供すべきです。

3. バンドルサイズ

ウェブアプリはロケールごとに翻訳をコード分割し、オンデマンドで読み込めます。モバイルアプリは単一のバイナリを出荷します。バンドルするすべての言語がアプリサイズを増加させます。50以上の言語では、ダウンロードサイズがメガバイト単位で増える可能性があります。これはフレームワーク間のアプローチを比較したモバイルアプリのローカライゼーションガイドで取り上げている中心的な課題の一つです。

4. アプリストアメタデータ

Google PlayとApp Storeは、サポートする各言語のローカライズされたメタデータ(タイトル、説明、スクリーンショット)を要求します。これはアプリ内翻訳とは別のワークフローです。Androidの場合、AndroidローカライゼーションガイドでPlay Storeメタデータのワークフローを詳しく説明しています。

5. RTLサポート

アラビア語、ヘブライ語、ペルシャ語、ウルドゥー語は右から左(RTL)のレイアウトが必要です。React Nativeは I18nManager を通じてRTLのサポートを内蔵していますが、明示的に処理する必要があります — レイアウトの反転、テキストの配置、方向アイコンなど。

プロジェクトのセットアップ

新しいExpoプロジェクトを開始するか、既存のプロジェクトに追加します:

npx create-expo-app my-localized-app
cd my-localized-app

依存関係をインストールします:

npx expo install expo-localization
npm install i18next react-i18next @better-i18n/react-native

各パッケージの役割:

パッケージ用途
expo-localizationデバイスのロケール検出とカレンダー・数値フォーマットの設定
i18next翻訳ランタイム(キー解決、補間、複数形処理)
react-i18nexti18nextのReactバインディング(フック、コンポーネント、コンテキスト)
@better-i18n/react-nativeBetter i18nのCDNデリバリー、オフラインキャッシュ、OTAアップデート

1. デバイスロケールの検出

最初のステップはユーザーの優先言語を検出することです。Expoはこのために expo-localization を提供しています:

// lib/i18n/locale.ts
import { getLocales, getCalendars } from "expo-localization";

export function getDeviceLocale(): string {
  const locales = getLocales();

  if (locales.length === 0) {
    return "en";
  }

  // 最初のロケールがユーザーの優先言語です
  const preferred = locales[0];

  // 言語コードを返します(例:"en"、"fr"、"tr")
  // 完全なロケールには languageTag を使用します(例:"en-US"、"fr-FR")
  return preferred.languageCode ?? "en";
}

export function getDeviceRegion(): string | null {
  const locales = getLocales();
  return locales[0]?.regionCode ?? null;
}

export function isRTL(): boolean {
  const locales = getLocales();
  return locales[0]?.textDirection === "rtl";
}

重要: getLocales() はユーザーの優先言語の順序付き配列を返します。iOSでは設定 → 一般 → 言語と地域から取得されます。Androidでは設定 → システム → 言語から取得されます。常に最初の要素を主要な優先言語として使用してください。

2. i18nextの設定

デバイスロケールの検出とBetter i18n CDNデリバリーでi18nextを設定します:

// lib/i18n/config.ts
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { createBetterI18nBackend } from "@better-i18n/react-native";
import { getDeviceLocale } from "./locale";

// バンドルされたフォールバック翻訳(アプリと一緒に出荷)
import en from "../../locales/en.json";
import es from "../../locales/es.json";

const fallbackResources = {
  en: { translation: en },
  es: { translation: es },
};

const betterI18nBackend = createBetterI18nBackend({
  project: "acme/mobile-app",
  cacheTTL: 60 * 60 * 1000, // 翻訳を1時間キャッシュ
  offlineStorage: true,      // オフライン利用のためAsyncStorageに永続化
});

i18n
  .use(betterI18nBackend)
  .use(initReactI18next)
  .init({
    lng: getDeviceLocale(),
    fallbackLng: "en",
    resources: fallbackResources, // CDNが到達不能な場合に使用
    interpolation: {
      escapeValue: false, // React Nativeがエスケープを処理
    },
    react: {
      useSuspense: true,
    },
  });

export default i18n;

フォールバックチェーンの仕組み:

  1. CDNフェッチ — Better i18n CDNから翻訳の読み込みを試みます
  2. オフラインキャッシュ — CDNに到達できない場合、AsyncStorageのキャッシュ翻訳を使用します
  3. バンドルリソース — キャッシュが存在しない場合、アプリに同梱された翻訳を使用します

この三層戦略により、インターネットなしの初回起動でも、アプリは常に翻訳を持つことができます。

3. 翻訳フックの使い方

コンポーネントで useTranslation フックを使用します:

// screens/HomeScreen.tsx
import { View, Text, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";

export function HomeScreen() {
  const { t } = useTranslation();

  return (
    <View style={styles.container}>
      <Text style={styles.title}>{t("home.title")}</Text>
      <Text style={styles.subtitle}>
        {t("home.welcome", { name: "Developer" })}
      </Text>
      <Text style={styles.count}>
        {t("home.itemCount", { count: 5 })}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 24, fontWeight: "bold" },
  subtitle: { fontSize: 16, marginTop: 8 },
  count: { fontSize: 14, marginTop: 4, color: "#666" },
});

翻訳ファイルの構造:

{
  "home": {
    "title": "Welcome",
    "welcome": "Hello, {{name}}!",
    "itemCount_one": "{{count}} item",
    "itemCount_other": "{{count}} items"
  }
}

複数形処理: i18nextは複数形を自動的に処理します。異なる複数形には _one_other_few_many サフィックスを使用します。これはアラビア語(6つの複数形)、ポーランド語(3つの形)、ロシア語(3つの形)などの言語にとって重要です。言語間の複数形ルールの詳細については、専用の複数形ガイドをご覧ください。

4. アプリ内言語ピッカー

アプリはデフォルトでデバイスロケールを使用すべきですが、ユーザーが上書きしたい場合もあります。以下は言語ピッカーコンポーネントです:

// components/LanguagePicker.tsx
import { View, Text, TouchableOpacity, FlatList, StyleSheet } from "react-native";
import { useTranslation } from "react-i18next";
import AsyncStorage from "@react-native-async-storage/async-storage";

const LANGUAGES = [
  { code: "en", name: "English", nativeName: "English" },
  { code: "es", name: "Spanish", nativeName: "Español" },
  { code: "fr", name: "French", nativeName: "Français" },
  { code: "de", name: "German", nativeName: "Deutsch" },
  { code: "tr", name: "Turkish", nativeName: "Türkçe" },
  { code: "ja", name: "Japanese", nativeName: "日本語" },
  { code: "ko", name: "Korean", nativeName: "한국어" },
  { code: "ar", name: "Arabic", nativeName: "العربية" },
];

export function LanguagePicker() {
  const { i18n } = useTranslation();

  const handleLanguageChange = async (code: string) => {
    await i18n.changeLanguage(code);
    await AsyncStorage.setItem("userLanguage", code);
  };

  return (
    <FlatList
      data={LANGUAGES}
      keyExtractor={(item) => item.code}
      renderItem={({ item }) => (
        <TouchableOpacity
          style={[
            styles.item,
            item.code === i18n.language && styles.selected,
          ]}
          onPress={() => handleLanguageChange(item.code)}
        >
          <Text style={styles.nativeName}>{item.nativeName}</Text>
          <Text style={styles.name}>{item.name}</Text>
        </TouchableOpacity>
      )}
    />
  );
}

const styles = StyleSheet.create({
  item: {
    flexDirection: "row",
    justifyContent: "space-between",
    padding: 16,
    borderBottomWidth: 1,
    borderBottomColor: "#eee",
  },
  selected: { backgroundColor: "#f0f7ff" },
  nativeName: { fontSize: 16, fontWeight: "600" },
  name: { fontSize: 14, color: "#666" },
});

永続化: ユーザーが手動で言語を選択した場合、AsyncStorage に保存します。アプリ起動時に、デバイスロケールにフォールバックする前に保存された設定を確認します:

// i18n設定の初期化時
const savedLanguage = await AsyncStorage.getItem("userLanguage");
const initialLanguage = savedLanguage || getDeviceLocale();

5. RTLレイアウトのサポート

アラビア語、ヘブライ語、その他のRTL言語については、React Nativeに明示的な設定が必要です:

// lib/i18n/rtl.ts
import { I18nManager } from "react-native";
import * as Updates from "expo-updates";

const RTL_LANGUAGES = ["ar", "he", "fa", "ur"];

export async function handleRTL(languageCode: string) {
  const shouldBeRTL = RTL_LANGUAGES.includes(languageCode);
  const isCurrentlyRTL = I18nManager.isRTL;

  if (shouldBeRTL !== isCurrentlyRTL) {
    I18nManager.forceRTL(shouldBeRTL);
    I18nManager.allowRTL(shouldBeRTL);

    // RTLの変更を有効にするにはアプリの再起動が必要
    if (!__DEV__) {
      await Updates.reloadAsync();
    }
  }
}

重要な注意: I18nManager.forceRTL() はアプリが再起動するまで有効になりません。開発中はホットリロードを使用できますが、本番環境ではレイアウト方向の変更を適用するために Updates.reloadAsync() を呼び出す必要があります。

RTL対応スタイル

I18nManager.isRTL を使用してスタイルを条件付きで反転します:

import { I18nManager, StyleSheet } from "react-native";

const styles = StyleSheet.create({
  row: {
    flexDirection: I18nManager.isRTL ? "row-reverse" : "row",
  },
  icon: {
    marginLeft: I18nManager.isRTL ? 0 : 8,
    marginRight: I18nManager.isRTL ? 8 : 0,
  },
});

Expo SDK 50以降では、leftright の代わりに startend プロパティを使用できます:

const styles = StyleSheet.create({
  container: {
    paddingStart: 16, // LTRでは左、RTLでは右
    paddingEnd: 8,    // LTRでは右、RTLでは左
  },
});

6. OTA翻訳アップデート

最大の機能:アプリストアに新しいバージョンを提出せずに翻訳を更新します。

// lib/i18n/ota.ts
import { AppState } from "react-native";
import i18n from "./config";

export function setupOTAUpdates() {
  // アプリがフォアグラウンドに来たときに新しい翻訳を確認
  const subscription = AppState.addEventListener("change", (state) => {
    if (state === "active") {
      refreshTranslations();
    }
  });

  return () => subscription.remove();
}

async function refreshTranslations() {
  try {
    // Better i18nバックエンドがキャッシュの無効化を処理します
    // これはキャッシュのTTLが期限切れの場合に新しいフェッチをトリガーします
    await i18n.reloadResources(i18n.language);
  } catch (error) {
    // サイレントフェイル — アプリはキャッシュされた翻訳で続行します
    console.warn("Translation refresh failed:", error);
  }
}

Better i18nでのOTAの仕組み:

  1. Better i18nプラットフォーム(AIドロワー、エディター、またはMCP経由)で翻訳を更新します
  2. CDNに変更を公開します
  3. アプリのキャッシュTTLが期限切れになり、ユーザーがアプリを開くと、新しい翻訳が取得されます
  4. UIはReactの再レンダリングサイクルを通じて自動的に更新されます

アプリストアへの提出不要。バイナリの更新も不要。翻訳は数分でライブになります。OTA翻訳デリバリーがクロスプラットフォームのローカライゼーション戦略にどう組み込まれるかについては、モバイルとウェブのOTA翻訳ガイドでアーキテクチャの意思決定を詳しく説明しています。

7. 日付、数値、通貨のフォーマット

ロケール対応のフォーマットには expo-localization を使用します:

// lib/i18n/formatting.ts
import { getLocales } from "expo-localization";

export function formatNumber(value: number): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  return new Intl.NumberFormat(locale).format(value);
}

export function formatCurrency(value: number, currency: string = "USD"): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency,
  }).format(value);
}

export function formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  return new Intl.DateTimeFormat(locale, options).format(date);
}

export function formatRelativeTime(date: Date): string {
  const locale = getLocales()[0]?.languageTag ?? "en-US";
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });

  const diffMs = date.getTime() - Date.now();
  const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));

  if (Math.abs(diffDays) < 1) {
    const diffHours = Math.round(diffMs / (1000 * 60 * 60));
    return rtf.format(diffHours, "hour");
  }

  return rtf.format(diffDays, "day");
}

注意: React NativeのHermesエンジンはHermes 0.12以降 Intl APIをサポートしています。古いバージョンを使用している場合は、intl-pluralrules@formatjs/intl-numberformat のポリフィルを追加してください。

8. ローカライズされたコンポーネントのテスト

異なるロケールでコンポーネントをテストします:

// __tests__/HomeScreen.test.tsx
import { render, screen } from "@testing-library/react-native";
import { I18nextProvider } from "react-i18next";
import i18n from "../lib/i18n/config";
import { HomeScreen } from "../screens/HomeScreen";

function renderWithI18n(component: React.ReactElement, locale: string = "en") {
  i18n.changeLanguage(locale);
  return render(
    <I18nextProvider i18n={i18n}>{component}</I18nextProvider>
  );
}

describe("HomeScreen", () => {
  it("英語でレンダリングされる", () => {
    renderWithI18n(<HomeScreen />, "en");
    expect(screen.getByText("Welcome")).toBeTruthy();
  });

  it("スペイン語でレンダリングされる", () => {
    renderWithI18n(<HomeScreen />, "es");
    expect(screen.getByText("Bienvenido")).toBeTruthy();
  });

  it("複数形処理が機能する", () => {
    renderWithI18n(<HomeScreen />, "en");
    expect(screen.getByText("5 items")).toBeTruthy();
  });
});

9. アプリストアのローカライゼーション

アプリストアのリスティングには別途ローカライゼーションが必要です。Expoは app.json でこれをサポートしています:

{
  "expo": {
    "name": "My App",
    "locales": {
      "es": "./locales/store/es.json",
      "fr": "./locales/store/fr.json",
      "de": "./locales/store/de.json",
      "tr": "./locales/store/tr.json",
      "ja": "./locales/store/ja.json"
    }
  }
}

各ロケールファイルにはアプリストアのメタデータが含まれています:

{
  "CFBundleDisplayName": "Mi Aplicación",
  "NSLocationWhenInUseUsageDescription": "Necesitamos tu ubicación para mostrarte tiendas cercanas."
}

iOSでは、ホーム画面と権限ダイアログのアプリ名がローカライズされます。Androidでは、Google Play ConsoleまたはFastlaneを通じてPlay Storeのメタデータを処理する必要があります。

本番環境チェックリスト

ローカライズされたReact Nativeアプリを出荷する前に:

  • デフォルトロケール検出がデバイス設定から機能する
  • フォールバック翻訳がアプリのバイナリにバンドルされている
  • オフラインモードがキャッシュされた翻訳で機能する
  • OTAアップデートがフォアグラウンドで新しい翻訳を取得する
  • RTLレイアウトがアラビア語またはヘブライ語でテストされている
  • 複数形処理がすべてのターゲット言語で機能する
  • 日付/数値フォーマットがデバイスロケールを尊重する
  • 言語ピッカーがユーザーの設定を永続化する
  • アプリストアメタデータがすべてのターゲット市場向けに翻訳されている
  • バンドルサイズが合理的である(npx expo export で確認)
  • パフォーマンス — 翻訳がUIをブロックせずに読み込まれる

まとめ

ExpoでReact Nativeアプリをローカライズすることはウェブのローカライゼーションよりも複雑ですが、ツールが大幅に成熟しました。デバイスの設定には expo-localization、翻訳ランタイムには i18next、CDNデリバリーとOTAアップデートにはBetter i18nを使用することで、翻訳ファイルを手作業で管理する複雑さなしに、本番環境に対応した多言語アプリを出荷できます。

重要な洞察:最小限の翻訳をバンドルし、残りはCDNから配信する。これによりアプリサイズを小さく保ちながら、OTAアップデートを通じて50以上の言語をサポートできます。ユーザーは自分の言語で翻訳を受け取り、翻訳が変更されるたびに新しいアプリバージョンを提出する必要はありません。

クロスプラットフォームのFlutterアプリにも同じ原則を適用したいチームには、Flutter intlローカライゼーションガイドでDartエコシステム向けの同等のセットアップを解説しています。


関連リソース

Comments

Loading comments...