Skip to content

Internationalization

Zelta Chat serves a global user base requiring:

  • Support for 42+ languages
  • Dynamic language switching without app restart
  • Fallback to English for missing translations
  • Right-to-left (RTL) language support
  • User preference persistence

Why i18n-js:

  • Lightweight and simple API
  • Support for pluralization and interpolation
  • Fallback mechanism for missing translations
  • No native dependencies
  • Compatible with JSON translation files

42 Languages:

  • European: English (en), German (de), French (fr), Spanish (es), Italian (it), Portuguese (pt, pt_BR), Dutch (nl), Polish (pl), Swedish (sv), Norwegian (no), Danish (da), Finnish (fi), Czech (cs), Romanian (ro), Croatian (hr), Hungarian (hu), Slovenian (sl), Serbian (sr, sh), Albanian (sq), Catalan (ca), Greek (el)
  • Asian: Chinese (zh, zh_CN, zh_TW), Japanese (ja), Korean (ko), Vietnamese (vi), Indonesian (id), Malay (ms), Tamil (ta), Malayalam (ml), Hindi (hi), Hebrew (he)
  • Middle Eastern: Arabic (ar), Persian/Farsi (fa), Turkish (tr), Urdu (ur), Azerbaijani (az)
  • Eastern European: Russian (ru), Ukrainian (uk)

Translation Keys Organization:

{
"AUTH": {
"LOGIN": "Login",
"EMAIL": "Email",
"PASSWORD": "Password"
},
"CONVERSATIONS": {
"TITLE": "Conversations",
"EMPTY_STATE": "No conversations found"
},
"ERRORS": {
"COMMON_ERROR": "Something went wrong",
"NETWORK_ERROR": "Network error"
}
}

Locale stored in Redux:

interface SettingsState {
locale: string; // 'en', 'es', 'fr', etc.
// ... other settings
}
App Launch
Redux Persist Rehydrates Settings
Get locale from settings.locale (default: 'es')
Set i18n.locale = locale
Load Translation Files
App Renders with Selected Language
User Changes Language in Settings
Dispatch: settingsActions.updateLocale(newLocale)
Update Redux: settings.locale = newLocale
Update i18n: i18n.locale = newLocale
Redux Persist Saves Setting
Components Re-render with New Translations
Component Calls: I18n.t('CONVERSATIONS.TITLE')
i18n-js Checks Current Locale
Lookup Translation Key in Current Locale
├─ Found: Return Translation
└─ Not Found:
Check Fallback Locale (English)
├─ Found: Return Fallback
└─ Not Found: Return Key

Configuration (src/i18n/index.js):

import i18n from 'i18n-js';
// Import all translations
import en from './en.json';
import es from './es.json';
import fr from './fr.json';
// ... more imports
// Set default locale
i18n.locale = 'es'; // Custom default
// Enable fallbacks
i18n.fallbacks = true; // Falls back to English
// Register translations
i18n.translations = {
en,
es,
fr,
de,
// ... all 42 languages
};
export default i18n;

Basic Translation:

import I18n from '@/i18n';
const MyComponent = () => {
return (
<View>
<Text>{I18n.t('CONVERSATIONS.TITLE')}</Text>
<Text>{I18n.t('CONVERSATIONS.EMPTY_STATE')}</Text>
</View>
);
};

Translation with Interpolation:

{
"MESSAGES": {
"GREETING": "Hello, {{name}}!",
"COUNT": "You have {{count}} new messages"
}
}
const greeting = I18n.t('MESSAGES.GREETING', { name: user.name });
const count = I18n.t('MESSAGES.COUNT', { count: unreadCount });

Pluralization:

{
"MESSAGES": {
"COUNT": {
"one": "{{count}} message",
"other": "{{count}} messages"
}
}
}
const message = I18n.t('MESSAGES.COUNT', { count: messageCount });

Update Locale (src/store/settings/settingsSlice.ts):

const settingsSlice = createSlice({
name: 'settings',
initialState: {
locale: 'es',
// ... other settings
},
reducers: {
updateLocale: (state, action: PayloadAction<string>) => {
state.locale = action.payload;
},
},
});

Apply Locale in Navigation (src/navigation/index.tsx):

export const AppNavigationContainer = () => {
const locale = useAppSelector(selectLocale);
// Update i18n locale when Redux state changes
i18n.locale = locale;
return (
<NavigationContainer>
<AppTabs />
</NavigationContainer>
);
};

For RTL languages (Arabic, Hebrew, Persian):

import { I18nManager } from 'react-native';
import I18n from '@/i18n';
const rtlLanguages = ['ar', 'he', 'fa'];
export const setupRTL = (locale: string) => {
const isRTL = rtlLanguages.includes(locale);
if (I18nManager.isRTL !== isRTL) {
I18nManager.forceRTL(isRTL);
// Requires app restart on Android
if (Platform.OS === 'android') {
Alert.alert(
I18n.t('SETTINGS.RESTART_REQUIRED'),
I18n.t('SETTINGS.RESTART_MESSAGE')
);
}
}
};

✅ Good Patterns:

// Use constants for translation keys
const TRANSLATION_KEYS = {
TITLE: 'CONVERSATIONS.TITLE',
EMPTY: 'CONVERSATIONS.EMPTY_STATE',
} as const;
const title = I18n.t(TRANSLATION_KEYS.TITLE);
// Provide fallback for missing translations
const message = I18n.t('KEY.THAT.MIGHT.NOT.EXIST', {
defaultValue: 'Fallback Message'
});
// Group related translations
{
"CONVERSATION_ACTIONS": {
"ASSIGN": "Assign",
"RESOLVE": "Resolve",
"REOPEN": "Reopen"
}
}

❌ Anti-Patterns:

// DON'T: Hardcode strings
<Text>Conversations</Text> // ❌ Use I18n.t()
// DON'T: Split translation keys unnecessarily
I18n.t('CONVERSATIONS') + ': ' + I18n.t('TITLE') // ❌ Use single key
// DON'T: Store translated strings in Redux
dispatch(setTitle(I18n.t('TITLE'))); // ❌ Store key, translate in render
// DON'T: Use complex interpolation logic
I18n.t('MESSAGE', {
value: someComplexCalculation()
}); // ❌ Calculate before translation
import I18n from '@/i18n';
describe('Internationalization', () => {
beforeEach(() => {
I18n.locale = 'en';
});
it('should return English translation', () => {
expect(I18n.t('CONVERSATIONS.TITLE')).toBe('Conversations');
});
it('should return Spanish translation', () => {
I18n.locale = 'es';
expect(I18n.t('CONVERSATIONS.TITLE')).toBe('Conversaciones');
});
it('should fallback to English for missing translation', () => {
I18n.locale = 'fr';
// If French translation missing, should return English
const result = I18n.t('MISSING.KEY');
expect(result).toBeDefined();
});
it('should interpolate values', () => {
const result = I18n.t('MESSAGES.GREETING', { name: 'John' });
expect(result).toContain('John');
});
});
describe('Translation Coverage', () => {
const baseTranslations = require('./en.json');
const languages = ['es', 'fr', 'de'];
languages.forEach(lang => {
it(`should have all keys in ${lang}`, () => {
const translations = require(`./${lang}.json`);
const baseKeys = getAllKeys(baseTranslations);
const langKeys = getAllKeys(translations);
expect(langKeys).toEqual(expect.arrayContaining(baseKeys));
});
});
});

For large translation files:

const translations: Record<string, any> = {};
export const loadTranslation = async (locale: string) => {
if (!translations[locale]) {
translations[locale] = await import(`./i18n/${locale}.json`);
i18n.translations[locale] = translations[locale];
}
};

For frequently used translations:

import { useMemo } from 'react';
const MyComponent = () => {
const translations = useMemo(() => ({
title: I18n.t('CONVERSATIONS.TITLE'),
empty: I18n.t('CONVERSATIONS.EMPTY_STATE'),
// ... more translations
}), []); // Re-compute only when locale changes
return <Text>{translations.title}</Text>;
};
  1. Create translation file:
Terminal window
cp src/i18n/en.json src/i18n/new-lang.json
  1. Translate content:
{
"CONVERSATIONS": {
"TITLE": "Translated Title",
"EMPTY_STATE": "Translated Empty State"
}
}
  1. Register in i18n:
import newLang from './new-lang.json';
i18n.translations = {
// ... existing
'new-lang': newLang,
};
{
"BUTTON": {
"SUBMIT": {
"LOGIN": "Sign In",
"SIGNUP": "Create Account",
"DEFAULT": "Submit"
}
}
}
const getSubmitText = (context: string) => {
return I18n.t(`BUTTON.SUBMIT.${context.toUpperCase()}`, {
defaultValue: I18n.t('BUTTON.SUBMIT.DEFAULT'),
});
};
scripts/validateTranslations.ts
import en from '../src/i18n/en.json';
import es from '../src/i18n/es.json';
const getAllKeys = (obj: any, prefix = ''): string[] => {
return Object.keys(obj).flatMap(key => {
const value = obj[key];
const fullKey = prefix ? `${prefix}.${key}` : key;
return typeof value === 'object'
? getAllKeys(value, fullKey)
: [fullKey];
});
};
const enKeys = getAllKeys(en);
const esKeys = getAllKeys(es);
const missingKeys = enKeys.filter(key => !esKeys.includes(key));
if (missingKeys.length > 0) {
console.error('Missing translations in Spanish:', missingKeys);
process.exit(1);
}