目次
モバイルアプリはユーザーのデバイス上で動作し、あなたが管理するサーバー上では動作しません。この違いにより、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-i18next | i18nextのReactバインディング(フック、コンポーネント、コンテキスト) |
@better-i18n/react-native | Better 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;
フォールバックチェーンの仕組み:
- CDNフェッチ — Better i18n CDNから翻訳の読み込みを試みます
- オフラインキャッシュ — CDNに到達できない場合、AsyncStorageのキャッシュ翻訳を使用します
- バンドルリソース — キャッシュが存在しない場合、アプリに同梱された翻訳を使用します
この三層戦略により、インターネットなしの初回起動でも、アプリは常に翻訳を持つことができます。
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以降では、left と right の代わりに start と end プロパティを使用できます:
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の仕組み:
- Better i18nプラットフォーム(AIドロワー、エディター、またはMCP経由)で翻訳を更新します
- CDNに変更を公開します
- アプリのキャッシュTTLが期限切れになり、ユーザーがアプリを開くと、新しい翻訳が取得されます
- 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エコシステム向けの同等のセットアップを解説しています。
関連リソース
- Next.js i18nガイド — Better i18nを使用したウェブ側のi18n
- 開発者ファーストのローカライゼーション — 開発者ファーストプラットフォームが優れている理由
- 国際化テスト — 多言語アプリの自動テスト戦略
- Better i18n ドキュメント — 完全なプラットフォームドキュメント