Skip to content

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 โ€‹

  1. Architecture Overview
  2. File Structure
  3. How Initialization Works
  4. RTL Support
  5. Using Translations in Components
  6. Adding or Editing Translations
  7. Adding a New Language
  8. Language Switching at Runtime
  9. Type Safety
  10. Translation Key Conventions
  11. Interpolation & Pluralization
  12. Common Patterns
  13. 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 โ€‹

LayerMechanismTakes effect
JS layoutdirection: 'rtl' on root GestureHandlerRootViewImmediately on first render
Native layoutI18nManager.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: 8

Use 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 โ€‹

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 โ€‹

  1. Create the locale file โ€” copy en.json to locales/fr.json and translate all values.

  2. 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'
  1. Update RTL list in rtl.ts if the language is RTL
ts
export const RTL_LANGUAGES = ['ar', 'he'] as const; // add 'he' for Hebrew
  1. Add UI strings in both en.json and ar.json for the language name:
jsonc
// en.json
"settings": { "french": "French" }

// ar.json
"settings": { "french": "ุงู„ูุฑู†ุณูŠุฉ" }
  1. 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 applies

The 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 error

TxKeyPath (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 โ€‹

RuleExample
Use snake_case for all keysview_all, next_level, heart_of_gold
Group by feature namespacehome.*, settings.*, common.*
Keep common.* for truly shared stringscommon.view_all
Use descriptive names, not positionshome.points.label not home.card1.text2
Interpolation variables in , ,
Plural forms use _one / _other suffixtasks_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-restart is 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.