
Global business
Software localization
This is part 1 of a two-part series. Check out part 2 here.
When considering our i18n and l10n laundry list, we'll want to handle the following right off the bat:
🗒 Note » If you're interested in web/browser i18n with React, we have an in-depth React i18n tutorial that covers just that.
🔗 Resource » Learn all the steps you need to make your JS apps ready for users around the globe in our Ultimate Guide to JavaScript Localization.
We'll use the Expo framework to get up and running quickly with our React Native app. i18next and Moment.js will help us build our i18n library for React Native.
Here are all the NPM libraries we'll use, with versions at the time of writing:
React Navigation will help us build the glue between the screens of our app. Speaking of which...
Our app will be a simple to-do list demo with:
🗒 Note » You can load the app on your Expo client by visiting https://expo.io/@mashour/react-native-i18n-demo and using the QR code for Android or "Request a Link" for iOS.
🗒 Note » You can also get all the app's code on its GitHub repo.
This is what our app will look like:
Alright, let's get started.
We can use the Expo CLI to initialize our app with expo init
from the command line. Once Expo spins up our project, we can create this directory structure to keep ourselves organized:
/ ├── src/ │ ├── components/ │ ├── config/ │ ├── lang/ │ ├── navigation/ │ ├── repos/ │ ├── screens/ │ ├── services/ │ │ └── i18n/ │ └── util/ └── App.js
i18next is an awesome JavaScript i18n library that's robust and extensible enough for us to use as the foundation of i18n in our React Native app. To cover our native mobile needs, we can build our own locale-detection plugin for i18next. The library will also allow us to plug-in custom translation loaders and date formatters. Let's get to all that.
🗒 Note » We have a dedicated article on i18next and Moment.js that is focused on web development.
First, let's get some configuration in place.
/src/config/i18n.js
export const fallback = "en"; export const supportedLocales = { en: { name: "English", translationFileLoader: () => require('../lang/en.json'), // en is default locale in Moment momentLocaleLoader: () => Promise.resolve(), }, ar: { name: "عربي", translationFileLoader: () => require('../lang/ar.json'), momentLocaleLoader: () => import('moment/locale/ar'), }, }; export const defaultNamespace = "common"; export const namespaces = [ "common", "lists", "ListScreen", "ListOfTodos", "AddTodoScreen", "DatePickerAndroid", ];
We setup our fallback locale that i18next will use if it doesn't find a translation for a given string in our current locale's translation file. The supportedLocales
map lists the locales our app covers, providing their translation files and the locale files Moment.js provides for date formatting.
We don't want to statically load all our translation files and Moment.js locale files, since that wouldn't scale well as we add more and more locales to our app. Instead, we want to dynamically load only the files relevant to our user's current locale. To do this, we can use the dynamic import()
construct for modules and require()
for static files.
However, the React Native JavaScript runtime doesn't allow for dynamic strings in its import
s and require
s. For example, import('../foo/' + bar)
would throw an error in React Native. So we wrap import expressions, with static paths to our files, in functions. This way we can invoke our functions to lazy-load our locale files once we've determined the user's current locale.
🗒 Note » React Native 0.56 removed dynamic import support from the framework. If you want to use dynamic imports with React Native 0.56+, check out the Babel Dynamic Import plugin.
You may have noticed our defaultNamespace
and namespaces
exports above. Namespaces are simply a way for us to logically group translations. For example, we could call i18next.t("HomeScreen:greeting")
to access the namespaced string at HomeScreen.greeting.
🗒 Note » You have to register every namespace with i18next before you use it. Otherwise, the library won't load your namespaces' translation strings. We've configured all the namespaces we'll use in our demo app above, and we'll wire them up with i18next shortly.
With our configuration in place, we can now wrap Moment.js in a module that will load localized date strings and providing a date formatting function.
/src/services/i18n/date.js
import moment from 'moment'; import * as config from '../../config/i18n'; const date = { /** * Load library, setting its initial locale * * @param {string} locale * @return Promise */ init(locale) { return new Promise((resolve, reject) => { config .supportedLocales[locale] .momentLocaleLoader() .then(() => { moment.locale(locale); return resolve(); }) .catch(err => reject(err)); }); }, /** * @param {Date} date * @param {string} format * @return {string} */ format(date, format) { return moment(date).format(format); } } export default date;
The date.init()
function takes an ISO 639-1 locale code, e.g. "en", and loads the locale's appropriate Moment.js locale module as per our configuration.
format()
is just a wrapper around Moment's formatting API, and will return a formatted date string corresponding to the currently loaded Moment.js locale.
i18next is nicely extensible, and allows us to plug in core parts of the library to suit our needs. We'll want to do this for language / locale detection, since in a pure Expo app we need to use Expo's localization library to dive into the native mobile environment and get the user's current locale.
/src/services/i18n/language-detector.js
import * as Localization from 'expo-localization'; const languageDetector = { type: 'languageDetector', async: true, detect: (callback) => { // We will get back a string like "en-US". We // return a string like "en" to match our language // files. callback(Localization.locale.split('-')[0]); }, init: () => { }, cacheUserLanguage: () => { }, }; export default languageDetector;
The two most important keys in our language detector are async
and detect
. The first designates our detector as asynchronous, so i18next will wait for us to invoke the given callback
in detect()
once we've figured out the user's current locale.
We find this locale by using Expo, which provides an add-on Localization
library that gets the user's locale as per her device settings. So if the user has set her mobile device's language to English (Canada), Localization.locale
will be "en-CA"
. We yank the "en"
part of the string out of the locale to match our language files, and let i18next know that we've detected the current locale by invoking callback("en")
.
🗒 Note » We're following i18next's plugin boilerplate here, and the library requires all of the languageDetector
's values, even ones we may not use. To get around this we just provide void-returning functions for the fields that don't interest us.
To keep our code nice and modular, let's make one more use of i18next's plugin system to quickly build out a translation loader.
Our translation files will look something like this.
/src/lang/en.json (excerpt)
{ "common": { "lists": "Lists" }, "lists": { "to-do": "To-do", "groceries": "Groceries", "learning": "Learning", "reading": "Reading" }, "ListScreen": { "empty": "No to-dos in this list! Use the + button to add a to-do." }, // ... }
We have namespaced keys that we have to grab to resolve our translation strings. With that in mind, we can write our loader.
/src/services/i18n/translation-loader.js
import * as config from '../../config/i18n'; const translationLoader = { type: 'backend', init: () => {}, read: function(language, namespace, callback) { let resource, error = null; try { resource = config .supportedLocales[language] .translationFileLoader()[namespace]; } catch (_error) { error = _error; } callback(error, resource); }, }; export default translationLoader;
Our loader's job is to make locale namespaces available to i18next. To resolve a namespace in our loader, we call our loader function, and given the locale's configuration, resolve the namespace in the loaded file. i18next will then do the work of refining further into the namespace and resolve a given key. So for "lists:groceries"
, we just have to provide the "lists"
bit when using i18next's translation function, t()
, in our UIs.
Let's use our loader and language detector plugins along with our date wrapper to build the core of our i18n service around i18next. We'll get locale direction , LTR or RTL, from the native environment through React Native's I18nManager
.
/src/services/i18n/index.js
import i18next from 'i18next'; import { I18nManager as RNI18nManager } from 'react-native'; import * as config from '../../config/i18n'; import date from './date'; import languageDetector from './language-detector'; import translationLoader from './translation-loader'; const i18n = { /** * @returns {Promise} */ init: () => { return new Promise((resolve, reject) => { i18next .use(languageDetector) .use(translationLoader) .init({ fallbackLng: config.fallback, ns: config.namespaces, defaultNS: config.defaultNamespace, interpolation: { escapeValue: false, format(value, format) { if (value instanceof Date) { return date.format(value, format); } } }, }, (error) => { if (error) { return reject(error); } date.init(i18next.language) .then(resolve) .catch(error => reject(error)); }); }); }, /** * @param {string} key * @param {Object} options * @returns {string} */ t: (key, options) => i18next.t(key, options), /** * @returns {string} */ get locale() { return i18next.language; }, /** * @returns {'LTR' | 'RTL'} */ get dir() { return i18next.dir().toUpperCase(); }, /** * @returns {boolean} */ get isRTL() { return RNI18nManager.isRTL; }, /** * Similar to React Native's Platform.select(), * i18n.select() takes a map with two keys, 'rtl' * and 'ltr'. It then returns the value referenced * by either of the keys, given the current * locale's direction. * * @param {Object<string,mixed>} map * @returns {mixed} */ select(map) { const key = this.isRTL ? 'rtl' : 'ltr'; return map[key]; } }; export const t = i18n.t; export default i18n;
Our i18n service is just an adapter around i18next with some added niceties. The i18n.init()
method gets our library booted up, initializing i18next with our plugins and namespaces, and using our custom date formatter in i18next's interpolation.format()
. i18next will have determined the current locale through Expo once it's initialized, and we can use this locale to initialize our date
wrapper via date.init()
.
🗒 Note » Since we generally output our strings to native mobile views and not a browser, HTML escaping will show unparsed HTML entities in React Native Text
and TextInput
. So we turn off i18next's HTML escaping by passing false
to interpolation.escapeValue
when we initialize the library. However, you may want to be careful if you're outputting text to a WebView, which displays a browser, or anywhere else web code can be harmful.
We wrap i18next's t
, language
, and dir
members with our own t,
locale
, and dir
, respectively, to provide a single API for our app's i18n. We will use the as we build our little to-do app.
Our isRTL
property relies on the React Native I18nManager
to determine layout and text direction from the native mobile environment. We use the native environment as the single source of truth for direction because we will sometimes need isRTL
before our i18n library has fully initialized (we'll see why a bit later). So we dig into the native environment for a more consistent source of locale direction.
select()
uses isRTL
and is a simple convenience method. We'll see how it works when we get to our views.
Let's use our i18n library in our main App
component.
/App.js
import { Updates } from 'expo'; import React, { Component } from 'react'; import { View, StyleSheet, ActivityIndicator, I18nManager as RNI18nManager, } from 'react-native'; import { createAppContainer } from 'react-navigation'; import i18n from './src/services/i18n'; import AppNavigator from './src/navigation/AppNavigator'; const AppNavigatorContainer = createAppContainer(AppNavigator); export default class App extends Component { state = { isI18nInitialized: false } componentDidMount() { i18n.init() .then(() => { const RNDir = RNI18nManager.isRTL ? 'RTL' : 'LTR'; // RN doesn't always correctly identify native // locale direction, so we force it here. if (i18n.dir !== RNDir) { const isLocaleRTL = i18n.dir === 'RTL'; RNI18nManager.forceRTL(isLocaleRTL); // RN won't set the layout direction if we // don't restart the app's JavaScript. Updates.reloadFromCache(); } this.setState({ isI18nInitialized: true }); }) .catch((error) => console.warn(error)); } render() { if (this.state.isI18nInitialized) { return <AppNavigatorContainer />; } return ( <View style={styles.loadingScreen}> <ActivityIndicator /> </View> ); } } const styles = StyleSheet.create({ loadingScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', } });
We don't want to show any app content before our i18n library is initialized, because our screens will have localized content that won't be ready until our i18n library is. Our state.isI18nInitialized
flag helps us with this, and allows us to conditionally load our root AppNavigatorContainer
only when our i18n is ready. We'll get to navigation in a bit. But first, you may have noticed this odd bit of code above:
/App.js (excerpt)
const RNDir = RNI18nManager.isRTL ? 'RTL' : 'LTR'; // RN doesn't always correctly identify native // locale direction, so we force it here. if (i18n.dir !== RNDir) { const isLocaleRTL = i18n.dir === 'RTL'; RNI18nManager.forceRTL(isLocaleRTL); // RN won't set the layout direction if we // don't restart the app's JavaScript. Updates.reloadFromCache(); }
Well, this has to do with how React Native with Expo handles layout direction. In the native environment, switching your device's language from, say, English to Arabic will automatically switch the OS's text and layout direction. Similarly, all native apps that support languages in two directions will automatically switch as well. However, React Native with Expo doesn't seem to currently do this out-of-the-box for right-to-left languages, and we have to force the RTL switching ourselves.
🗒 Note » If you're not using Expo or create-react-native-app, or otherwise have access to native code, there does seem to be a way to configure your native environment to enable React Native's own RTL switching. Check out Facebook's official blog post on RTL support for React Native apps for more information.
On app load we check if the i18next locale direction matches what React Native thinks the direction is. If these two values don't match we need to force React Native's direction. This won't take effect immediately, however, and our JavaScript app needs to be restarted to complete the process. The Updates.reloadFromCache()
is meant for reloading our app's JavaScript bundle, and we use it to do just that to finalize the direction switch.
🗒 Note » We only perform our direction-switching logic when our i18next locale direction is different than React Native's. This is important because restarting the JavaScript bundle can take a noticeable amount of time, so we don't want to do it on every app load. In production the switch should realistically only happen on the first load of our app, since the majority of users don't change their system language after initially setting up their device.
🗒 Another note » In my experimentation the above layout direction issue only affects iOS. Android seems to behave a bit better here. Also if you noticed that Expo's Localization
has its own isRTL
property, and that we're not using it here, then know we have good reason to do so: we're preferring I18nManager.isRTL
here because on iOS Expo's Localization.isRTL
can get it wrong sometimes. React Native's I18nManager
seems to be reliable, however. Make sure to test your app on the OS configurations you support to make sure that you're getting expected behavior from them.
That's about it for our i18n scaffolding. Let's get to our app's Navigator
component.
/src/navigation/AppNavigator.js
import React, { Component } from 'react'; import { createStackNavigator } from 'react-navigation-stack'; import { createDrawerNavigator } from 'react-navigation-drawer'; import lists from '../config/lists'; import i18n, { t } from '../services/i18n'; import DrawerContent from './DrawerContent'; import ListScreen from '../screens/ListScreen'; import AddTodoScreen from '../screens/AddTodoScreen'; function getListNavItems() { return lists.reduce((items, name) => { const stackNavigator = createStackNavigator({ [name]: ListScreen, AddTodoScreen, }); stackNavigator.navigationOptions = ({ navigation }) => ({ title: t(`lists:${navigation.state.routeName}`), }); return { ...items, [name]: stackNavigator }; }, {}); }; const AppNavigator = createDrawerNavigator( getListNavItems(), { contentComponent: DrawerContent, drawerPosition: i18n.isRTL ? 'right' : 'left', }, ); export default AppNavigator;
We're using React Navigation to build our screen navigation. Covering React Navigation in detail is a bit outside the scope of this article, but we'll go over what we're basically doing here. Our AppNavigator
is a DrawerNavigator
, commonly found in Android apps and modern websites. It allows us to slide open a drawer of navigable items.
Each of the items inside our DrawerNavigator
is a StackNavigator
which allows its internal screens to open up on top of one another, and provides the ability to back out of a screen to see the screen before it. We build a StackNavigator
for each one of our pre-loaded lists, making sure to use our t()
function to show localized names for our list titles. Our lists
are stored in a configuration file, and are just a list of string keys that we can use to retrieve translated string names. We nest all of these StackNavigators
under our root DrawerNavigator
. This allows us to achieve the following navigation structure in our app.
DrawerNavigator │ ├── To-do StackNavigator │ ├── ListScreen │ └── AddTodoScreen │ ├── Groceries StackNavigator │ ├── ListScreen │ └── AddTodoScreen | ├── Reading StackNavigator │ ├── ListScreen │ └── AddTodoScreen ...
In practice this looks a bit like this:
[youtube https://www.youtube.com/watch?v=ypaXgBE1YDQ?rel=0&controls=0&showinfo=0&w=560&h=315]
React Navigation's StackNavigator
is largely bi-directional out of the box and there's very little we need to do to make it right-to-left outside of the configuration we already set in App.js
. However, our DrawerNavigator
needs a bit more work. You may have noticed that in the code above we had to set its layout direction explicitly depending on our locale's direction.
To get a header in our DrawerNavigator
we need to provide a custom content component for the navigator.
/src/navigation/DrawerContent.js
import React, { Component } from 'react'; import { ScrollView, Text, StyleSheet } from 'react-native'; import { SafeAreaView } from 'react-navigation'; import { DrawerItems } from 'react-navigation-drawer'; import { t } from '../services/i18n'; const DrawerContent = (props) => ( <ScrollView> <SafeAreaView style={styles.container} forceInset={{ top: 'always', horizontal: 'never' }} > <Text style={styles.header}>{t('lists')}</Text> <DrawerItems {...props} /> </SafeAreaView> </ScrollView> ); const styles = StyleSheet.create({ container: { flex: 1, paddingTop: 40, }, header: { fontSize: 18, fontWeight: '100', textAlign: 'left', marginStart: 16, marginBottom: 8, } }); export default DrawerContent;
This is based on the official React Navigation documentation for custom drawer content. We can pass the props that React Navigation gives our component to its own DrawerItems
, since we're not customizing those. The main bit of customization we're doing here is that we're adding a header on top of our drawer items.
🗒 Note » You may have noticed that there is no namespace in our t('lists')
call. That's because the lists
key belongs to the common
namespace, which we registered as the default namespace with i18next.
Of special importance to us are the textAlign
and marginStart
style props. Many React Native left / right layout props like margin and padding have direction-agnostic equivalents. These will adapt to the current locale's direction. So by using marginStart
we get our margin on the left in LTR locales, and on the right in RTL locales.
However, we don't need to use these direction-agnostic props in React Native if we don't want to. React Native will map marginLeft
to marginStart
, and marginRight
to marginEnd
, behind the scenes. So if we had set marginLeft: 16
above, our header would have 16 points of margin to its left in English, and 16 points of margin to its right in Arabic.
Text alignment also gets this default mapping. So our textAlign: 'left'
above will make sure that our header's text is aligned to the left in English and aligned to the right in Arabic.
🗒 Note » For directional text alignment to work, we must explicitly specify the direction. So if we omitted the textAlign
prop above or set it to 'auto'
, our header's text wouldn't always respect our locale's direction. When we explicitly set it to 'left'
, however, we get the desired behavior.
Also, according to Facebook, Android and iOS handle default text alignment a bit differently. "In iOS, the default text alignment depends on the active language bundle, they are consistently on one side. In Android, the default text alignment depends on the language of the text content, i.e. English will be left-aligned and Arabic will be right-aligned." This is yet another reason to explicitly set our textAlign
.
Ok that's it for scaffolding. In the next part of this series, we'll go over building our app's screens.
🗒 Note » You can load the app on your Expo client by visiting https://expo.io/@mashour/react-native-i18n-demo and using the QR code for Android or "Request a Link" for iOS.
🗒 Note » You can peruse all of the app's code on its GitHub repo.
Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately, Phrase can make your life as a developer easier! Feel free to learn more about Phrase, referring to the Phrase Localization Suite.
I think React Native is one of a few libraries that are paving the way for a new generation of cross-platform native mobile development frameworks. The coolest thing about React Native is that it uses React and JavaScript to allow for a lean, declarative, component-based approach to mobile development.
React Native brings React's easy-to-debug, uni-directional data flow to mobile, and opens up a ton of JavaScript NPM packages for use in mobile development. The framework is still maturing, and one of the areas that are still not under lock-and-key is i18n and l10n with RN. I hope I shed some light on that topic here, and I hope you'll join me as we round out our app in part 2 of this series.
Last updated on January 15, 2023.