
Global business
Software localization
In part 1 of this series, we built an i18n library for React Native on top of i18next, Moment.js, and Expo. We also built the scaffolding for our app's navigation. You may want to check out part 1 before proceeding, as this article builds on the library we established in part 1.
🔗 Resource » Check out our Ultimate Guide to JavaScript Localization if you haven't already. We'll cover everything you need to make your JS apps accessible to international users, with lots of insights on different frameworks and more.
As a quick recap, let's go over our external dependencies and what our app's functionality is about.
Here are all the NPM libraries we'll use, with versions at the time of writing:
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.
🗒 Another 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.
Our first view will be the ListScreen
, which displays a single list's to-do items.
We'll be making use of a ListRepo
repository that offers a to-do CRUD wrapper around React Native's AsyncStorage
abstraction. We use the repository to persist to-dos locally on the user's device. For brevity, we won't cover the repo code here. The repo isn't crazy complex at all, and you can view the repository's code on GitHub if you're coding along.
The main models we use with our ListRepo
is our preloaded lists, which are just a map of to-dos. Each to-do is a map whose keys are the id of the to-do, and whose values have the following shape:
/src/screens/ListScreen.js
{ id: string, due: Date?, text: string?, isComplete: boolean, }
Now let's take a look at how we can build out our ListScreen
.
/src/screens/ListScreen.js
import React, { Component } from 'react'; import { Text, View, StyleSheet, ActivityIndicator, } from 'react-native'; import { t } from '../services/i18n'; import ListRepo from '../repos/ListRepo'; import ListHeaderStart from './ListHeaderStart'; import AddButton from '../components/AddButton'; import ListOfTodos from '../components/ListOfTodos'; class ListScreen extends Component { static navigationOptions = ({ navigation }) => { return { title: t(`lists:${navigation.state.routeName}`), headerLeft: <ListHeaderStart navigation={navigation} />, }; }; constructor(props) { super(props); this.state = { todos: [], isLoading: true, }; const listName = props.navigation.state.routeName; this.repo = new ListRepo(listName); this.willFocusSubscription = props.navigation.addListener( 'willFocus', () => this.loadTodosWithIndicator(), ); } componentWillUnmount() { this.willFocusSubscription.remove(); } async loadTodosWithIndicator() { this.setState({ isLoading: true }); const todos = await this.getKeyedTodosArray(); this.setState({ todos, isLoading: false }); } async getKeyedTodosArray() { return this.transformToKeyedArray( await this.repo.getTodos() ); } refreshTodos = async () => { const todos = await this.getKeyedTodosArray(); this.setState({ todos }); } /** * @param {Object<string, { id: string }} todos * @return {Array<Object>} */ transformToKeyedArray(todos) { return Object.keys(todos) .reverse().map(id => ({ ...todos[id], key: id })); } updateItem = async (item) => { await this.repo.updateTodo(item); this.refreshTodos(); } deleteItem = async (item) => { await this.repo.deleteTodo(item); this.refreshTodos(); } goToAddTodoScreen = () => { this.props.navigation.navigate('AddTodoScreen', { listName: this.props.navigation.state.routeName, }); } renderContent() { if (this.state.todos.length === 0) { return ( <Text style={styles.emptyListText}> {t('ListScreen:empty')} </Text> ); } return ( <ListOfTodos todos={this.state.todos} onItemUpdate={this.updateItem} onItemDelete={this.deleteItem} /> ); } render() { return ( <View style={styles.container}> {this.state.isLoading ? <ActivityIndicator /> : this.renderContent() } <AddButton style={styles.addButton} onPress={this.goToAddTodoScreen} /> </View> ); } } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, emptyListText: { fontSize: 16, marginHorizontal: 30, }, addButton: { position: 'absolute', bottom: 30, end: 30, }, }); export default ListScreen;
A bunch of our ListScreen
's methods focus on CRUD. We're also using React Navigation to display a navigation bar. We can customize this bar using the static navigationOptions
property, which our StackNavigator
will use when displaying our screen.
/src/screens/ListScreen.js (excerpt)
class ListScreen extends Component { static navigationOptions = ({ navigation }) => { return { title: t(`lists:${navigation.state.routeName}`), headerLeft: <ListHeaderStart navigation={navigation} />, }; }; // ...
Earlier, we designated each of our list routes to be the name of that list in lowercase English. So our Groceries list will have the routeName
of "groceries"
. This routeName
can then be used to key into our current locale's translation file via t("lists:groceries")
to display our screen's title in the navbar.
/src/lang/en.json (excerpt)
"lists": { "to-do": "To-do", "groceries": "Groceries", "learning": "Learning", "reading": "Reading" }
In the case of the "groceries" ListScreen
in English, our navbar title will be "Groceries"
.
We often want to customize the right and left items of our navigation headers. In the case of ListScreen
, we want to place a button that opens up our DrawerNavigator
to allow our users to navigate between different lists. We can do this via the headerLeft
prop on navigationOptions
. The cool thing is React Navigation will automatically switch layout direction for the current locale. So, without adding any extra code, a headerLeft
component will be displayed to the left of the header in English, and to the right of the header in Arabic.
Let's take a look at our add button's positioning and then come back to our custom headerLeft
component.
You will have noticed our "Add to-do" FAB (floating action button) on our ListScreen
. This kind of button comes from Google's Material Design, and is a common UI pattern in apps these days. Users will expect that our FAB is floating above everything else in the screen, and an easy way to achieve this layout is through absolute positioning.
/src/screens/ListScreen.js (excerpt)
addButton: { position: 'absolute', bottom: 30, end: 30, },
This styling will float our FAB at the bottom-right corner of the screen in English, and above all other screen UI. Notice our use of the direction-agnostic end
style prop instead of right
. We could have used right
, and React Native would have mapped that to end
for us. In both cases, our FAB will be displayed near the right edge of the screen in English, and near the left edge of the screen in Arabic.
🗒 Note » There are, of course, start
/ left
counterpart props to right
/ end
for absolute positioning.
Let's dive into our FAB component's code.
/src/components/AddButton.js
import { Text, Platform, StyleSheet, TouchableOpacity, } from 'react-native'; import React, { Component } from 'react'; import i18n from '../services/i18n'; class AddButton extends Component { static defaultProps = { style: {}, } render() { return ( <TouchableOpacity onPress={this.props.onPress} style={[styles.addButton, this.props.style]} > <Text style={styles.addButtonText}>+</Text> </TouchableOpacity> ); } } const styles = StyleSheet.create({ addButton: { width: 50, height: 50, ...i18n.select({ rtl: { paddingEnd: 1 }, ltr: { paddingStart: 1 }, }), paddingBottom: 3, alignItems: 'center', justifyContent: 'center', borderRadius: 25, backgroundColor: Platform.OS === 'ios' ? '#0076FF' : '#2962FF', ...Platform.select({ ios: { shadowOpacity: 0.3, shadowRadius: 1, shadowColor: 'black', shadowOffset: { width: 0, height: 1 }, }, android: { elevation: 2, } }) }, addButtonText: { fontSize: 30, color: 'white', textAlign: 'center', }, }); export default AddButton;
This builds out a circular button with a "+" in the middle of it. Notice this bit of styling:
/src/components/AddButton.js (excerpt)
...i18n.select({ rtl: { paddingEnd: 1 }, ltr: { paddingStart: 1 }, }),
Our button's "+" sign doesn't center perfectly on the horizontal axis within the button, so we add a slight amount of padding to correct it. We always want to add left padding to the button, regardless of layout direction. However, if we simply use paddingLeft
, React Native will map that to paddingStart
and we'll get padding on the left in English, and on the right in Arabic.
Confusing, no? Well, we can use our i18n.select()
method to target RTL and LTR separately. Explicit use of paddingStart
and paddingEnd
can make our intention clear: We want left (end) padding for RTL layouts, and the same left (start) padding for LTR layouts. That's exactly what the i18n.select()
expression above is giving us.
🗒 Note » Our i18n.select()
wouldn't work reliably if our i18n library used i18next for locale direction resolution. This is because i18n.select()
is being called within StyleSheet.create()
, which exists outside of our component's class or function. This means that StylesSheet.create()
will get evaluated when its file is imported, which will happen earlier than component instantiation. i18next may not have fully initialized then. This is exactly why we rely on React Native's I18nManager
for locale direction resolution. I18nManager
will be ready when our file is imported, so it's more reliable than i18next for our purposes here.
Ok, let's revisit our ListScreen
's headerLeft
component.
/src/screens/ListScreen.js (excerpt)
static navigationOptions = ({ navigation }) => { return { title: t(`lists:${navigation.state.routeName}`), headerLeft: <ListHeaderStart navigation={navigation} />, }; };
We have a ListHeaderStart
that we pass to our StackNavigator
's headerLeft
option.
/src/screens/ListHeaderStart.js
import React, { Component } from 'react'; import { StyleSheet } from 'react-native'; import IconButton from '../components/IconButton'; class ListHeaderStart extends Component { openListsDrawer = () => { this.props.navigation.openDrawer(); } render() { return ( <IconButton style={styles.button} icon="format-list-bulleted" onPress={this.openListsDrawer} /> ); } } const styles = StyleSheet.create({ button: { marginStart: 20, }, }); export default ListHeaderStart;
This component corresponds to the button that opens our DrawerNavigator
.
Notice our IconButton
in the above code. This is a reusable UI component that has a bit of direction logic that comes in handy for LTR / RTL layouts.
/src/components/IconButton.js
import React, { Component } from 'react'; import { TouchableOpacity, StyleSheet } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import i18n from '../services/i18n'; class IconButton extends Component { static defaultProps = { style: {}, color: '#333', flipForRTL: true, iconComponent: MaterialCommunityIcons, } getFlipForRTLStyle() { if (!this.props.flipForRTL) { return {}; }; return { transform: [{ scaleX: i18n.isRTL ? -1 : 1, }], }; } render() { return ( <TouchableOpacity onPress={this.props.onPress} style={[styles.button, this.getFlipForRTLStyle(), this.props.style]} > <this.props.iconComponent size={24} name={this.props.icon} color={this.props.color} /> </TouchableOpacity> ); } } const styles = StyleSheet.create({ button: { width: 40, height: 40, paddingTop: 2, paddingStart: 1, alignItems: 'center', justifyContent: 'center', }, }); export default IconButton;
Of special interest to us is the flipForRTL
prop and its companion method, getFlipForRTLStyle()
.
/src/components/IconButton.js (excerpt)
getFlipForRTLStyle() { if (!this.props.flipForRTL) { return {}; }; return { transform: [{ scaleX: i18n.isRTL ? -1 : 1, }], }; }
We use React Native's transform
style prop to flip the icon on the horizontal axis for RTL layouts. We also make this behaviour opt-out through the flipForRTL
boolean prop, so that we can disable directional icon flipping when we don't want it.
You may have noticed that our ListScreen
makes use of a ListOfTodos
component. This component wraps a React Native FlatList
to display a list's to-dos.
/src/components/ListOfTodos.js
import React, { Component } from 'react'; import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { Text, View, StyleSheet, FlatList, Platform } from 'react-native'; import Checkbox from './Checkbox'; import IconButton from './IconButton'; import i18n, { t } from '../services/i18n'; class ListOfTodos extends Component { toggleComplete(item) { const newItem = {...item, isComplete: !item.isComplete }; this.props.onItemUpdate(newItem); } renderRow = ({ item }) => { return ( <View style={styles.row}> <Checkbox style={styles.checkbox} checked={item.isComplete} onToggle={() => this.toggleComplete(item)} /> <Text numberOfLines={1} style={styles.text} > {item.text} </Text> <View style={styles.dueDateContainer}> {!!item.due && <View style={styles.dueDateInner}> <MaterialCommunityIcons size={18} color="#555" name="calendar-clock" /> <Text style={styles.dueDateText}> {formatDate(item.due)} </Text> </View> } </View> <IconButton icon="trash-2" flipForRTL={false} iconComponent={Feather} style={styles.deleteButton} onPress={() => this.props.onItemDelete(item)} color={Platform.OS === 'ios' ? '#FF3824' : '#F44336'} /> </View> ); } render() { return ( <FlatList data={this.props.todos} style={styles.flatList} renderItem={this.renderRow} /> ); } } /** * @param {Date} date * @returns {String} */ function formatDate(date) { if (date.getYear() === new Date().getYear()) { return t("ListOfTodos:dueDateShort", { date }); } return t("ListOfTodos:dueDateFull", { date }); } const styles = StyleSheet.create({ flatList: { width: '100%', }, row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingVertical: 10, paddingStart: 20, paddingEnd: 10, }, checkbox: { width: 40, ...i18n.select({ rtl: { marginBottom: 4 }, }), }, deleteButton: { ...i18n.select({ ltr: { marginBottom: 4 }, rtl: { marginBottom: 6 }, }), }, text: { flex: 1, fontSize: 18, color: '#333', textAlign: 'left', }, dueDateContainer: { width: 80, height: 30, }, dueDateInner: { height: '100%', flexDirection: 'row', alignItems: 'center', }, dueDateText: { marginStart: 4, fontSize: 12, fontWeight: '100', color: '#555', }, }); export default ListOfTodos;
Our ListofTodos
wraps a FlatList
that takes an array of to-do items takes a Notice, however, that basic row LTR / RTL is handled for us by React Native because we already set up layout direction in our App.js
. We didn't need to add any code to manage our layout direction at the list or row levels.
Our to-dos' due dates are being conditionally formatted to provide a somewhat better user experience.
/src/components/ListOfTodos.js (excerpt)
/** * @param {Date} date * @returns {String} */ function formatDate(date) { if (date.getYear() === new Date().getYear()) { return t("ListOfTodos:dueDateShort", { date }); } return t("ListOfTodos:dueDateFull", { date }); }
When a to-do's due date is within the current year, we only want to show the day and the month of the due date. When the due date is past this year, we want to show the full date. Date formatting is handled by our custom date formatter, which uses Moment.js. We hook into it by providing some special syntax in our translation strings.
/src/lang/ar.json (excerpt)
"ListOfTodos": { "dueDateShort": "{{date, DD MMM}}", "dueDateFull": "{{date, DD/MM/YYYY}}" },
We're using i18next's interpolation by passing in the date
as a param when we call t()
. And by providing the , DD MMM
syntax, we let i18next know that we want our date to be formatted as per our given format string. Moment.js understands many format strings, and handles the date formatting for us because we wired it up to do so in our i18n library.
Notice our delete button and its styles.
/src/components/ListOfTodos.js (excerpt)
<IconButton icon="trash-2" flipForRTL={false} iconComponent={Feather} style={styles.deleteButton} onPress={() => this.props.onItemDelete(item)} color={Platform.OS === 'ios' ? '#FF3824' : '#F44336'} /> // ... deleteButton: { ...i18n.select({ ltr: { marginBottom: 4 }, rtl: { marginBottom: 6 }, }), },
We turn off RTL-flipping for the delete button's icon via flipForRTL={false}
. This doesn't really make a difference here since the button is symmetrical on the horizontal axis. However, if we had added direction-specific horizontal styling to the button, like for example...
deleteButton: { ...i18n.select({ ltr: { paddingStart: 2 }, rtl: { paddingEnd: 1 }, }), },
...we would get unexpected results if we hadn't set flipForRTL
to false
. This is because the prop will reverse our directional styling. So it's important to be aware of the interplay of flipping through transformation and direction-specific styling.
We can now build out our AddTodoScreen
.
/src/screens/AddTodoScreen.js
import React, { Component } from 'react'; import { Text, View, Button, Platform, TextInput, StyleSheet, } from 'react-native'; import ListRepo from '../repos/ListRepo'; import i18n, { t } from '../services/i18n'; import DatePicker from '../components/DatePicker'; class AddTodoScreen extends Component { static navigationOptions = () => ({ title: t("AddTodoScreen:title"), }) state = { text: '', due: new Date(), hasChosenDueDate: false, isDatePickerOpen: false, } onChangeText = text => this.setState({ text }) onAddDatePressed = () => { this.setState({ isDatePickerOpen: true, hasChosenDueDate: true, }); } onDateChange = due => { if (due === null) { this.setState({ isDatePickerOpen: false }); } else { this.setState({ due }); } } onSavePressed = async () => { const { text, hasChosenDueDate } = this.state; const due = hasChosenDueDate ? this.state.due : null; await ListRepo .with(this.props.navigation.state.params.listName) .addTodo({ text, due }); this.props.navigation.goBack(); } render() { return ( <View style={styles.container}> <View style={styles.form}> <Text style={styles.label}> {t("AddTodoScreen:todoLabel")} </Text> <View style={styles.textInputContainer}> <TextInput numberOfLines={1} returnKeyType="done" style={styles.textInput} value={this.state.text} onChangeText={this.onChangeText} underlineColorAndroid="transparent" placeholder= {t("AddTodoScreen:todoPlaceholder")} /> </View> <Text style={styles.label}> {t("AddTodoScreen:dueDateLabel")} </Text> {this.state.isDatePickerOpen ? <DatePicker date={this.state.due} minDate={new Date()} onDateChange={this.onDateChange} /> : <Button title={t("AddTodoScreen:addDueDateButton")} onPress={this.onAddDatePressed} /> } </View> <Button title={t("AddTodoScreen:saveButton")} onPress={this.onSavePressed} /> </View> ); } } const styles = StyleSheet.create({ container: { flex: 1, paddingTop: 20, paddingBottom: 35, paddingHorizontal: 16, }, form: { flex: 1, }, label: { fontSize: 18, color: '#333', marginBottom: 8, textAlign: 'left', }, textInputContainer: { borderWidth: 1, borderColor: '#ddd', marginBottom: 40, ...Platform.select({ ios: { shadowOpacity: 0.1, shadowRadius: 1, shadowColor: 'black', shadowOffset: { width: 0, height: 1 }, }, android: { elevation: 2, } }) }, textInput: { height: 44, width: '100%', fontSize: 18, paddingHorizontal: 8, color: '#333', backgroundColor: 'white', textAlign: i18n.isRTL ? 'right' : 'left', }, }); export default AddTodoScreen;
To-dos have text and a due date when we add them. We'll get to due dates and date pickers in a moment. First, let's take a closer look at our text input and its styles.
/src/screens/AddTodoScreen.js
<TextInput numberOfLines={1} returnKeyType="done" style={styles.textInput} value={this.state.text} onChangeText={this.onChangeText} underlineColorAndroid="transparent" placeholder= {t("AddTodoScreen:todoPlaceholder")} /> // ... textInput: { height: 44, width: '100%', fontSize: 18, color: '#333', paddingHorizontal: 8, backgroundColor: 'white', textAlign: i18n.isRTL ? 'right' : 'left', },
Notice that we're setting our TextInput
's alignment to match the current locale's direction: textAlign: i18n.isRTL ? 'right' : 'left'
. That's because, weirdly enough, React Native does not seem to map textAlign: 'left'
to textAlign: 'start'
when it comes to TextInput
s. So unlike the Text
component, we actually just straight-up tell TextInput
s the exact text alignment they should conform to. This alignment will affect both inputted text and placeholders.
To add due dates, we use a DatePicker
component in our AddTodoScreen
. We actually fork here and provide one date picker for Android and another for iOS, since the two platforms can handle date input somewhat differently. We can just expose a unified API to our platform-specific pickers, and let React Native auto-load the correct component by including platform-specific extensions in our filenames.
Let's start with Android.
/src/components/DatePicker.android.js
import React, { Component } from 'react'; import { DatePickerAndroid, Text } from 'react-native'; import { t } from '../services/i18n'; class DatePicker extends Component { state = { hasSelectedDate: false, } componentDidMount() { const { date, minDate } = this.props; DatePickerAndroid .open({ date, minDate }) .then(({ action, year, month, day }) => { if (action !== DatePickerAndroid.dismissedAction) { this.setState({ hasSelectedDate: true }); this.props.onDateChange(new Date(year, month, day)); } else { this.props.onDateChange(null); } }) .catch(({ message }) => console.warn(`Could not open DatepickerAndroid: ${message}`)); } render() { if (this.state.hasSelectedDate) { return ( <Text> {t("DatePickerAndroid:dueDate", { date: this.props.date })} </Text> ); } return null; } } export default DatePicker;
The React Native DatePickerAndroid
API is more imperative than most of the framework's other components and doesn't really provide any JSX. We simply .open()
the picker and wait for a promise to resolve. open()
's promise resolution indicates that the user has either canceled out of the picker or chosen a date. We make sure that the allowable date range for selection starts at today's date and extends into the future since it doesn't make sense for a to-do to have a due date in the past. Unless you have a time machine. No, you can't borrow mine. To be honest I'm not sure you want to. Time-travel isn't always a picnic.
If the user has indeed selected a date we render it out as formatted text. That's really about it for our Android picker.
Ok, time for iOS.
/src/components/DatePicker.ios.js
import React from 'react'; import { DatePickerIOS } from 'react-native'; import i18n from '../services/i18n'; const DatePicker = (props) => ( <DatePickerIOS mode="date" date={props.date} locale={i18n.locale} minimumDate={props.minDate} onDateChange={props.onDateChange} /> ); export default DatePicker;
React Native's DatePickerIOS
is more React-y than its Android counterpart, and provides a spinner date selector rather than a modal. In the case of iOS, we have to explicitly supply the locale so that the picker renders the correct numbers and names.
🗒 Note » I couldn't really find a way to get DatePickerIOS
to display an RTL spinner set that is ordered day, month, year from right to left. This, to my mind, would be the natural ordering of a date picker in Arabic. If you have information to the contrary or have found way to achieve the desired ordering, please leave a comment below. However, providing the locale
prop will at least get numbers and month titles to appear in the current language.
Well, that's about it. At this point, we have a small working to-do list app that demonstrates several of the problems we may encounter when i18n-izing a React Native app, and some solutions to those problems.
🗒 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.
🗒 A final 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 area that is still not under lock-and-key is i18n and l10n with RN. I hope I shed some light on that topic here, and I certainly hope you enjoyed reading this two-part series (check out part 1 if you haven't already). It's an exciting time to be a developer. Happy coding, amigos.
Last updated on January 15, 2023.