Internationalisation
Pull every string into a per-locale catalogue, read the device language with expo-localization, and fall back when a key is missing.
const i18n = new I18n(translations); i18n.locale = getLocales()[0].languageCode ?? "en";Internationalisation (i18n) means no component contains a visible English string. Every string lives in a catalogue, one per locale, and the component asks for a key. Expo SDK 57 ships expo-localization to read the device setting; i18n-js v4 does the lookup.
How it works
Keep a plain object per language, bundled with the app. new I18n({ en, ar }) builds the store. i18n.t("home.title") looks a key up in the current locale, and %{name} placeholders in the entry are filled from the second argument. Set i18n.defaultLocale and i18n.enableFallback = true so a key missing from Arabic falls back to English instead of rendering a missing-translation marker.
The device language comes from getLocales(), which returns the user's locales in the order they ranked them in the OS settings, so element 0 is their first choice. Each entry carries languageTag ("en-GB"), languageCode ("en"), regionCode and textDirection. languageCode can be null, so the usual line is getLocales()[0].languageCode ?? "en" — shorthand for code === null || code === undefined ? "en" : code.
What trips people up
Assigning i18n.locale is not a React update. Nothing re-renders until state changes, so hold the chosen locale in state and pass it down, or reload the app. Also resist assembling sentences yourself: a template literal cannot be reordered by a translator, while a %{name} placeholder inside the catalogue entry can.
Example
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { getLocales } from "expo-localization";
import { I18n } from "i18n-js";
// One object per language. Real projects move these into JSON files.
const translations = {
en: { greeting: "Hello, %{name}", other: "العربية" },
ar: { greeting: "مرحبا، %{name}", other: "English" },
};
const i18n = new I18n(translations);
i18n.defaultLocale = "en";
i18n.enableFallback = true; // a key missing from ar is served from en
// ?? is shorthand for: code === null || code === undefined ? "en" : code
i18n.locale = getLocales()[0].languageCode ?? "en";
export default function GreetingScreen() {
// The locale lives in state, because assigning i18n.locale re-renders nothing.
const [locale, setLocale] = useState(i18n.locale);
const toggle = () => {
const next = locale === "en" ? "ar" : "en";
i18n.locale = next;
setLocale(next);
};
return (
<View style={styles.screen}>
<Text style={styles.greeting}>{i18n.t("greeting", { name: "Sam" })}</Text>
<Pressable onPress={toggle} style={styles.button}>
<Text style={styles.label}>{i18n.t("other")}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16 },
greeting: { fontSize: 20 },
button: { backgroundColor: "#1f6feb", borderRadius: 8, paddingHorizontal: 18, paddingVertical: 10 },
label: { color: "#ffffff", fontSize: 16 },
});When to use it
- Ship one build that shows Arabic or English depending on the phone's language setting
- Let the user pick a language in a settings row, overriding the device default
- Keep all copy in JSON files so a translator never opens a component
- Serve English when a key has not been translated yet, instead of a placeholder
More examples
A locale module the rest of the app imports
Reach for this as soon as there is more than one screen. The catalogue, the fallback rules and the locale choice are decided in one file, and components only import i18n. pickLocale matters because the device may be set to fr-CA while you ship only en and ar: taking getLocales()[0] blindly leaves the app on a locale with no translations, where enableFallback quietly serves English anyway but i18n.locale no longer tells you what the user is seeing.
// i18n.ts - created once, imported everywhere
import { getLocales } from "expo-localization";
import { I18n } from "i18n-js";
import en from "./locales/en.json";
import ar from "./locales/ar.json";
export const i18n = new I18n({ en, ar });
i18n.defaultLocale = "en";
i18n.enableFallback = true;
// Walk the user's ranked languages and take the first one this app ships.
export function pickLocale(shipped: string[], ranked: string[]): string {
const match = ranked.find((tag) => shipped.includes(tag.split("-")[0]));
return match === undefined ? "en" : match.split("-")[0];
}
i18n.locale = pickLocale(
["en", "ar"],
getLocales().map((locale) => locale.languageTag)
);
// Output: pickLocale(["en", "ar"], ["fr-CA", "ar-EG"]) -> "ar"
// Output: pickLocale(["en", "ar"], ["ja-JP"]) -> "en"Reacting when the user changes language in the OS settings
A header bar with a translated title and subtitle that re-translate themselves if the user backgrounds the app, switches language in Settings and comes back. useLocales is the hook form of getLocales: same data, but it triggers a re-render when the OS value changes, which a one-off call at module scope cannot do. Passing { locale } to t() on each call keeps the render pure, instead of mutating the shared i18n object while React is rendering.
import { useLocales } from "expo-localization";
import { StyleSheet, Text, View } from "react-native";
import { i18n } from "./i18n";
export default function Header() {
// useLocales re-renders this component when the OS language changes
// while the app is running. Longhand: const first = useLocales()[0];
const [first] = useLocales();
const locale = first.languageCode ?? "en";
return (
<View style={styles.bar}>
{/* passing locale per call keeps render pure - no assignment to i18n.locale */}
<Text style={styles.title}>{i18n.t("home.title", { locale })}</Text>
<Text style={styles.subtitle}>{i18n.t("home.subtitle", { locale })}</Text>
</View>
);
}
const styles = StyleSheet.create({
bar: { paddingHorizontal: 16, paddingTop: 48, paddingBottom: 12 },
title: { fontSize: 22, fontWeight: "600" },
subtitle: { fontSize: 15, color: "#57606a", marginTop: 4 },
});Letting the user override the language
Two tappable rows, each naming a language in that language, with a filled dot beside the selected one. This is the settings screen for an app whose users do not want the device language. The component owns no locale state: the parent holds it and passes locale plus onChange, so one place decides what the current locale is. Type Locale is derived from the LOCALES array, so adding a language to the array is the only edit needed.
import { Pressable, StyleSheet, Text, View } from "react-native";
import { i18n } from "./i18n";
const LOCALES = ["en", "ar"] as const;
type Locale = (typeof LOCALES)[number];
type LanguagePickerProps = {
locale: Locale;
onChange: (next: Locale) => void;
};
export default function LanguagePicker({ locale, onChange }: LanguagePickerProps) {
return (
<View style={styles.group}>
{LOCALES.map((option) => (
<Pressable key={option} onPress={() => onChange(option)} style={styles.row}>
{/* each language is named in its own language, not the current one */}
<Text style={styles.label}>{i18n.t(`languages.${option}`, { locale: option })}</Text>
<View style={[styles.dot, option === locale && styles.dotOn]} />
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
group: { paddingHorizontal: 16 },
row: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: 14 },
label: { fontSize: 16 },
dot: { width: 14, height: 14, borderRadius: 7, borderWidth: 1, borderColor: "#8c959f" },
dotOn: { backgroundColor: "#1f6feb", borderColor: "#1f6feb" },
});
Discussion