Appearance
Touli โ Internationalization (i18n) Guide โ
All translation infrastructure lives in
apps/mobile/src/lib/i18n/.
Default language: English (en) ยท Secondary language: Arabic (ar)
Table of Contents โ
- Architecture Overview
- File Structure
- How Initialization Works
- RTL Support
- Using Translations in Components
- Adding or Editing Translations
- Adding a New Language
- Language Switching at Runtime
- Type Safety
- Translation Key Conventions
- Interpolation & Pluralization
- Common Patterns
- Troubleshooting
1. Architecture Overview โ
i18next โโโถ react-i18next โโโถ Components (useTranslation / translate)
โ
โโโ resources (en.json + ar.json loaded at build time, no network)
โโโ MMKV storage (persists user language choice across sessions)
โโโ RTL module (I18nManager + direction style on root view)The app ships all locales as bundled JSON โ no CDN fetch, no cold-start delay. Language preference is stored in MMKV so it survives restarts.
2. File Structure โ
src/lib/i18n/
โโโ index.tsx # Entry point โ initialises i18next, calls setupRTL
โโโ resources.ts # Imports JSONs and exports typed resources object
โโโ utils.tsx # translate(), useSelectedLanguage(), changeLanguage()
โโโ rtl.ts # isRTL flag, dir() helper, setupRTL()
โโโ types.ts # RecursiveKeyOf utility type for dot-path inference
โโโ react-i18next.d.ts # Module augmentation โ wires types into react-i18next
โโโ locales/
โโโ en.json # English strings (used as the source-of-truth type)
โโโ ar.json # Arabic strings (must mirror every key in en.json)3. How Initialization Works โ
index.tsx runs once when the module is first imported (typically from _layout.tsx):
1. i18n.init() โ resources: { en, ar }
lng: MMKV value ?? 'en' (English is the default)
fallbackLng: 'en'
compatibilityJSON: 'v4'
2. setupRTL(i18n)
โโโ reads i18n.dir() โ 'rtl' | 'ltr'
โโโ sets isRTL (JS flag, effective immediately)
โโโ calls I18nManager.forceRTL() (native layer โ needs restart)
โโโ if native state changed โ RNRestart.restart()_layout.tsx imports { isRTL } from @/lib/i18n which guarantees the module runs before the first render. The GestureHandlerRootView then receives direction: isRTL ? 'rtl' : 'ltr' which mirrors the entire flex tree immediately, even on first launch before the native restart fires.
4. RTL Support โ
Two-layer approach โ
| Layer | Mechanism | Takes effect |
|---|---|---|
| JS layout | direction: 'rtl' on root GestureHandlerRootView | Immediately on first render |
| Native layout | I18nManager.forceRTL(true) + RNRestart.restart() | After one restart |
isRTL flag โ
ts
import { isRTL } from '@/lib/i18n';
// In JSX โ evaluated at render time (live value)
<View style={isRTL ? styles.wrapperRTL : styles.wrapperLTR} />isRTL is a module-level let that setupRTL writes before the first component renders. Babel compiles named imports as live getter accesses, so every component always reads the current value.
dir() helper โ inline directional values โ
ts
import { dir } from '@/lib/i18n';
// Returns the second argument when RTL, the first when LTR
const marginStart = dir(0, 16); // 0 in LTR, 16 in RTL
const position = dir('left', 'right');Directional styles โ always prefer logical properties โ
ts
// โ
Correct โ mirrors automatically with layout direction
paddingStart: 12, paddingEnd: 8
marginStart: 4, marginEnd: 4
// โ Avoid โ fixed side, doesn't mirror
paddingLeft: 12, paddingRight: 8Use left / right only when the position must be physically fixed (e.g. a toast that always appears at the device's physical right edge).
5. Using Translations in Components โ
Option A โ useTranslation hook (recommended for functional components) โ
tsx
import { useTranslation } from 'react-i18next';
export function MyComponent() {
const { t } = useTranslation();
return <Text>{t('home.greeting', { name: 'Ahmad' })}</Text>;
}Option B โ translate() function (outside components / callbacks) โ
ts
import { translate } from '@/lib/i18n';
const label = translate('settings.title');translate is memoized with lodash.memoize. The cache key includes serialised options so interpolated strings are cached per unique argument set.
Option C โ tx prop on the Text atom โ
The custom Text component accepts a tx prop that calls translate internally:
tsx
import { Text } from '@/lib/components/ui';
<Text tx="common.view_all" />;6. Adding or Editing Translations โ
Step 1 โ Edit en.json first (source of truth) โ
en.json drives the TypeScript types. Add your key, then TypeScript will flag any component that tries to use a key that doesn't exist.
jsonc
// locales/en.json
{
"home": {
"new_section": {
"title": "My New Section",
"description": "This is a description with a {{variable}}.",
},
},
}Step 2 โ Mirror the key in ar.json โ
Every key that exists in en.json must exist in ar.json. Missing keys fall back to ar (the fallbackLng), which means users see Arabic even when English is selected โ keep them in sync.
jsonc
// locales/ar.json
{
"home": {
"new_section": {
"title": "ูุณู
ู ุงูุฌุฏูุฏ",
"description": "ูุฐุง ูุตู ู
ุน {{variable}}.",
},
},
}Step 3 โ Use in a component โ
tsx
const { t } = useTranslation();
t('home.new_section.title');
t('home.new_section.description', { variable: 'value' });TypeScript will auto-complete the key path and show an error for typos. โ
7. Adding a New Language โ
Create the locale file โ copy
en.jsontolocales/fr.jsonand translate all values.Register in
resources.ts
ts
import ar from './locales/ar.json';
import en from './locales/en.json';
import fr from './locales/fr.json'; // โ add
export const resources = {
en: { translation: en },
ar: { translation: ar },
fr: { translation: fr }, // โ add
};
export type Language = keyof typeof resources; // 'en' | 'ar' | 'fr'- Update RTL list in
rtl.tsif the language is RTL
ts
export const RTL_LANGUAGES = ['ar', 'he'] as const; // add 'he' for Hebrew- Add UI strings in both
en.jsonandar.jsonfor the language name:
jsonc
// en.json
"settings": { "french": "French" }
// ar.json
"settings": { "french": "ุงููุฑูุณูุฉ" }- Expose the option in the language picker (
language-item.tsx).
8. Language Switching at Runtime โ
ts
import { useSelectedLanguage } from '@/lib/i18n';
export function LanguagePicker() {
const { language, setLanguage } = useSelectedLanguage();
return (
<Pressable onPress={() => setLanguage('ar')}>
<Text>{language === 'ar' ? 'ุงูุนุฑุจูุฉ' : 'Arabic'}</Text>
</Pressable>
);
}What happens when setLanguage is called โ
setLanguage('ar')
โโโ MMKV.set('local', 'ar') saves choice so next launch picks it up
โโโ changeLanguage('ar')
โโโ i18n.changeLanguage() updates react-i18next state
โโโ translate.cache.clear() flushes memoized translations
โโโ I18nManager.allowRTL()
โโโ I18nManager.forceRTL() writes RTL pref to native storage
โโโ RNRestart.restart() full native restart โ RTL layout appliesThe restart is required because React Native's flex mirroring (I18nManager.isRTL) can only change at the native process level. The restart is near-instant (~300 ms).
9. Type Safety โ
The whole key path tree is inferred automatically from en.json.
ts
// react-i18next.d.ts
declare module 'react-i18next' {
type CustomTypeOptions = {
resources: (typeof resources)['en']; // โ locks types to en.json shape
};
}This gives you:
- Auto-complete on every
t('...')call - Compile-time errors for misspelled or missing keys
- Hover documentation showing the English string value
ts
t('home.greeting'); // โ
resolves to "Hello, {{name}}!"
t('home.greetting'); // โ TypeScript error: key does not exist
t('home.badges.unknownKey'); // โ TypeScript errorTxKeyPath (exported from utils.tsx) is the union of all valid dot-path strings. Use it when passing translation keys as props:
tsx
type Props = { labelKey: TxKeyPath };
<Text tx={props.labelKey} />;10. Translation Key Conventions โ
| Rule | Example |
|---|---|
Use snake_case for all keys | view_all, next_level, heart_of_gold |
| Group by feature namespace | home.*, settings.*, common.* |
Keep common.* for truly shared strings | common.view_all |
| Use descriptive names, not positions | home.points.label not home.card1.text2 |
Interpolation variables in | , , |
Plural forms use _one / _other suffix | tasks_one, tasks_other (see ยง11) |
11. Interpolation & Pluralization โ
Interpolation โ
jsonc
// en.json
"greeting": "Hello, {{name}}!"tsx
t('home.greeting', { name: user.displayName });
// โ "Hello, Ahmad!"Pluralization (i18next v4 format) โ
i18next resolves plural keys automatically based on the count option.
jsonc
// en.json
"tasks_to_complete_one": "{{count}} Task to Complete",
"tasks_to_complete_other": "{{count}} Tasks to Complete"tsx
t('home.challenges.tasks_to_complete', { count: 3 });
// โ "3 Tasks to Complete"
t('home.challenges.tasks_to_complete', { count: 1 });
// โ "1 Task to Complete"Arabic has six plural forms. i18next handles them with the suffixes _zero, _one, _two, _few, _many, _other:
jsonc
// ar.json
"tasks_to_complete_zero": "ูุง ุชูุฌุฏ ู
ูุงู
",
"tasks_to_complete_one": "ู
ูู
ุฉ ูุงุญุฏุฉ ููุฅูุฌุงุฒ",
"tasks_to_complete_two": "ู
ูู
ุชุงู ููุฅูุฌุงุฒ",
"tasks_to_complete_few": "{{count}} ู
ูุงู
ููุฅูุฌุงุฒ",
"tasks_to_complete_many": "{{count}} ู
ูู
ุฉ ููุฅูุฌุงุฒ",
"tasks_to_complete_other": "{{count}} ู
ูุงู
ููุฅูุฌุงุฒ"12. Common Patterns โ
Conditional text based on direction โ
tsx
import { dir, isRTL } from '@/lib/i18n';
// Flip a unidirectional arrow glyph
const arrow = dir('โ', 'โ');
// Flip an absolute-positioned element
const badgeStyle = isRTL ? styles.badgeRTL : styles.badgeLTR;Reading current language โ
ts
import i18n from '@/lib/i18n';
const lang = i18n.language; // 'ar' | 'en'
const dir = i18n.dir(); // 'rtl' | 'ltr'Watching language changes reactively โ
tsx
import { useTranslation } from 'react-i18next';
const { i18n } = useTranslation();
// i18n.language updates when changeLanguage() is called
// (before restart โ useful for web)13. Troubleshooting โ
Text still shows LTR after switching to Arabic โ
The app requires a full native restart for I18nManager.isRTL to flip. This happens automatically via RNRestart.restart() in changeLanguage() and setupRTL(). If it looks like the restart isn't firing:
- Check that
react-native-restartis installed and linked. - In Expo, ensure you are running a dev client build (
expo-dev-client), not Expo Go โ Expo Go does not support all native modules.
Layout mirrors but text is still left-aligned โ
The Text atom sets writingDirection and textAlign from isRTL, not I18nManager.isRTL. If you bypassed the Text atom and used React Native's raw <Text>, add these styles manually:
tsx
<RNText style={{ writingDirection: 'rtl', textAlign: 'right' }}>ุงููุต ุงูุนุฑุจู</RNText>TypeScript error: "key does not exist" โ
The key is missing from en.json. Add it there first โ the type is derived from en.json, not ar.json.
Stale translations after changeLanguage() โ
translate() is memoized. changeLanguage() calls translate.cache.clear?.() to flush the cache before restarting. If you call i18n.changeLanguage() directly (bypassing the wrapper), call translate.cache.clear?.() yourself.
fallbackLng showing Arabic in English mode โ
A key exists in en.json but is missing from ar.json. Keep both files in sync. Use a tool like i18next-parser in CI to catch missing keys automatically.