32
React Native Internationalization with i18next
Originally published on my personal blog
We are going to build the React Native app that:
- supports multiple languages (with react-i18next library)
- gets translations from a Google sheet and writes it straight to the app (with google-spreadsheet library)
- sets default language based on a user's device locale
- stores a user's language choice in Async storage
- has a Language Picker component
Assume we have a basic React Native project. For this blog post, I'm going to use expo project, but the steps will be the same for a project that was initialized with React Native CLI.
The app has just one screen that renders the text "Hello!" and a button with the title "Press".
The source code:
//App.tsx
import { StatusBar } from "expo-status-bar";
import React from "react";
import { StyleSheet, Text, View, Button } from "react-native";
export default function App() {
return (
<View style={styles.container}>
<Text style={styles.text}>Hello!</Text>
<Button title="Press" onPress={() => Alert.alert("HELLO")} />
<StatusBar style="auto" />
</View>
);
}
//styles omitted
Now we are going to add support of multiple languages in our app.
First of all, we need to add react-i18next to our project by running
npm i react-i18next i18next
This will install i18next framework and its React wrapper.
Next we need to configure it by creating a new file, let's say i18n.config.ts (or any other name as you like), at the top level of the project:
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
//empty for now
const resources = {};
i18n.use(initReactI18next).init({
resources,
//language to use if translations in user language are not available
fallbackLng: "en",
interpolation: {
escapeValue: false, // not needed for react!!
},
});
export default i18n;
A list of all config options is available in the docs.
Then just import this file in the entry point of your project: App.tsx for expo projects or index.ts/index.js for React Native CLI projects.
//App.tsx
import { StatusBar } from "expo-status-bar";
import React from "react";
import { StyleSheet, Text, View, Button } from "react-native";
import "./i18n.config"; // <-- this line added
export default function App() {
return (
<View style={styles.container}>
<Text style={styles.text}>Hello!</Text>
<Button title="Press" onPress={() => Alert.alert("HELLO")} />
<StatusBar style="auto" />
</View>
);
}
All translations we are going to add into a separate folder - translations - with a separate JSON file for each supported language.
//translations folder structure
├── translations/
│ ├── be.json
│ ├── de.json
│ ├── en.json
│ ├── es.json
│ ├── fr.json
Usually, the app is being translated by other team members (if your team is international), or by hired interpreters, or by special translation tools. One of the most convenient ways would be to store all translations in a Google Sheet and then automatically generate JSON files and upload them to the project source code - translations folder.
Create a Google Sheet with the following structure:
Column A will have translations keys (HELLO, PRESS, etc). These values will be used as keys in JSON files with translations. Columns B-F will contain translations themselves, the first row - supported languages names (en - English, es - Spanish, fr - French, and so on).
After adding all translations, the Google sheet should look like this:
Now let's move to the fun part - writting a script that:
- will read translations from the Google Sheet
- will write them straight into the translations folder of the project, each language translations into their respective JSON file, and properly formatted.
For reading data from the Google Sheet, we are going to use the google-spreadsheet library. Let's add it to our project:
npm i google-spreadsheet
The next thing we need to handle is v4 Google sheet API authentication. You can read about it in the google-sheet library docs. I'm going to use the Service account option for this blog post.
Once you followed the steps from the docs, you should have a JSON file with the following keys:
{
"type": "service_account",
"project_id": "XXXXXXXXXXXXXXX",
"private_key_id": "XXXXXXXXXXXXXXX",
"private_key": "XXXXXXXXXXXXXXX",
"client_email": "service-account-google-sheet-a@XXXXXXXXXXXX.iam.gserviceaccount.com",
"client_id": "XXXXXXXXXXXXXXX",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/service-account-google-sheet-XXXXXXXXXXXXX.iam.gserviceaccount.com"
}
Create another folder in your React Native project (I'm going to call it utils) and put this JSON file there. Don't forget to add it to .gitignore!
Now initialize a Google sheet instance in the React Native project.
//utils/script.js
const { GoogleSpreadsheet } = require("google-spreadsheet");
const secret = require("./secret.json");
// Initialize the sheet
const doc = new GoogleSpreadsheet("<YOUR_GOOGLE_SHEET_ID");
// Initialize Auth
const init = async () => {
await doc.useServiceAccountAuth({
client_email: secret.client_email, //don't forget to share the Google sheet with your service account using your client_email value
private_key: secret.private_key,
});
};
You can find the spreadsheet ID in a Google Sheets URL:
https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0
My spreadsheet ID looks like this:
1hDB6qlijcU5iovtSAisKqkcXhdVboFd1lg__maKwvDI
Now write a function that reads data from our Google sheet with translations.
//utils/script.js
...
const read = async () => {
await doc.loadInfo(); // loads document properties and worksheets
const sheet = doc.sheetsByTitle.Sheet1; //get the sheet by title, I left the default title name. If you changed it, then you should use the name of your sheet
await sheet.loadHeaderRow(); //Loads the header row (first row) of the sheet
const colTitles = sheet.headerValues; //array of strings from cell values in the first row
const rows = await sheet.getRows({ limit: sheet.rowCount }); //fetch rows from the sheet (limited to row count)
let result = {};
//map rows values and create an object with keys as columns titles starting from the second column (languages names) and values as an object with key value pairs, where the key is a key of translation, and value is a translation in a respective language
rows.map((row) => {
colTitles.slice(1).forEach((title) => {
result[title] = result[title] || [];
const key = row[colTitles[0]];
result = {
...result,
[title]: {
...result[title],
[key]: row[title] !== "" ? row[title] : undefined,
},
};
});
});
return result;
};
If you run this script
cd utils && node script.js
and print the result object (add console.log(result) before return), you should get the following result:
{
en: { HELLO: 'Hello', PRESS: 'Press' },
fr: { HELLO: 'Bonjour', PRESS: 'Presse' },
es: { HELLO: 'Hola', PRESS: 'Prensa' },
de: { HELLO: 'Hallo', PRESS: 'Drücken Sie' },
be: { HELLO: 'Прывітанне', PRESS: 'Прэс' }
}
Next, we need to write this result object in the translations folder, each file per key.
//utils/script.js
...
const fs = require("fs");
...
const write = (data) => {
Object.keys(data).forEach((key) => {
fs.writeFile(
`../translations/${key}.json`,
JSON.stringify(data[key], null, 2),
(err) => {
if (err) {
console.error(err);
}
}
);
});
};
So here:
- we get the result object for the read function as a param
- loop through the keys of this object
- write values of a key of the result object (e.g., translations) into a JSON file using Node.js file system module (fs) formatted with JSON.stringify() method.
And finally, chain all the above async methods:
//utils/script.js
...
init()
.then(() => read())
.then((data) => write(data))
.catch((err) => console.log("ERROR!!!!", err));
Now if you run the script again:
node script.js
all the translations should be written in the translations folder as separate JSON files for each language.
To be able to use these translations in our React Native project, we need to:
- export these JSON files from the tranlsations folder
//utils/index.js
export { default as be } from "./be.json";
export { default as en } from "./en.json";
export { default as de } from "./de.json";
export { default as es } from "./es.json";
export { default as fr } from "./fr.json";
- update i18n.config.ts file:
//i18n.config.ts
...
import { en, be, fr, de, es } from "./translations";
const resources = {
en: {
translation: en,
},
de: {
translation: de,
},
es: {
translation: es,
},
be: {
translation: be,
},
fr: {
translation: fr,
},
};
...
Now we can translate the content of the app with the help of useTranslation hook provided by react-i18next library.
//App.tsx
...
import { useTranslation } from "react-i18next";
export default function App() {
const { t } = useTranslation();
return (
<View style={styles.container}>
<Text style={styles.text}>{`${t("HELLO")}!`}</Text>
<Button title={t("PRESS")} onPress={() => Alert.alert(t("HELLO"))} />
<StatusBar style="auto" />
</View>
);
}
//styles omitted
To switch between supported languages in the app, build Language Picker component:
//LanguagePicker.tsx
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Modal, View, Text, Pressable, StyleSheet } from "react-native";
const LanguagePicker = () => {
const [modalVisible, setModalVisible] = useState(false);
const { i18n } = useTranslation(); //i18n instance
//array with all supported languages
const languages = [
{ name: "de", label: "Deutsch" },
{ name: "en", label: "English" },
{ name: "fr", label: "Français" },
{ name: "be", label: "Беларуская" },
{ name: "es", label: "Español" },
];
const LanguageItem = ({ name, label }: { name: string; label: string }) => (
<Pressable
style={styles.button}
onPress={() => {
i18n.changeLanguage(name); //changes the app language
setModalVisible(!modalVisible);
}}
>
<Text style={styles.textStyle}>{label}</Text>
</Pressable>
);
return (
<View>
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
onRequestClose={() => {
setModalVisible(!modalVisible);
}}
>
<View style={styles.centeredView}>
<View style={styles.modalView}>
{languages.map((lang) => (
<LanguageItem {...lang} key={lang.name} />
))}
</View>
</View>
</Modal>
<Pressable
style={[styles.button, styles.buttonOpen]}
onPress={() => setModalVisible(true)}
>
//displays the current app language
<Text style={styles.textStyle}>{i18n.language}</Text>
</Pressable>
</View>
);
};
export default LanguagePicker;
//styles omitted
Add the Language Picker component in the App.tsx:
//App.tsx
import { StatusBar } from "expo-status-bar";
import React from "react";
import { StyleSheet, Text, View, Button } from "react-native";
import "./i18n.config";
import { useTranslation } from "react-i18next";
import LanguagePicker from "./LanguagePicker";
export default function App() {
const { t } = useTranslation();
return (
<View style={styles.container}>
<LanguagePicker />
<Text style={styles.text}>{`${t("HELLO")}!`}</Text>
<Button title={t("PRESS")} onPress={() => Alert.alert(t("HELLO"))} />
<StatusBar style="auto" />
</View>
);
}
Let's check how it works now:
The internationalization works as expected, but wouldn't it be nice to store a user's language choice, so after a user opens the app his/her previously selected language is used by default?
i18next comes with several React Native plugins to enhance the features available. But let's try to write the custom plugin from scratch that:
- stores the user's language choice in Async storage
- gets the saved language from Async storage on the app start
- if there is nothing stored in Async storage, detect a device's language. If it's not supported, use fallback language.
How to create a custom plugin is described in a separate section of i18next docs. For our use case, we need a languageDetector plugin.
Let's get our hands dirty!
- for expo app
expo install @react-native-async-storage/async-storage
- for React Native CLI or expo bare React Native app
npm i @react-native-async-storage/async-storage
Additional step for iOS (not needed for expo project):
npx pod-install
expo install expo-localization
For React Native CLI or expo bare React Native app also follow these additional installation instructions.
//utils/languageDetectorPlugin.ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Localization from "expo-localization";
const STORE_LANGUAGE_KEY = "settings.lang";
const languageDetectorPlugin = {
type: "languageDetector",
async: true,
init: () => {},
detect: async function (callback: (lang: string) => void) {
try {
//get stored language from Async storage
await AsyncStorage.getItem(STORE_LANGUAGE_KEY).then((language) => {
if (language) {
//if language was stored before, use this language in the app
return callback(language);
} else {
//if language was not stored yet, use device's locale
return callback(Localization.locale);
}
});
} catch (error) {
console.log("Error reading language", error);
}
},
cacheUserLanguage: async function (language: string) {
try {
//save a user's language choice in Async storage
await AsyncStorage.setItem(STORE_LANGUAGE_KEY, language);
} catch (error) {}
},
};
module.exports = { languageDetectorPlugin };
//i18n.config.ts
...
const { languageDetectorPlugin } = require("./utils/languageDetectorPlugin");
...
i18n
.use(initReactI18next)
.use(languageDetectorPlugin)
.init({
resources,
//language to use if translations in user language are not available
fallbackLng: "en",
interpolation: {
escapeValue: false, // not needed for react!!
},
react: {
useSuspense: false, //in case you have any suspense related errors
},
});
...
And that's it! We have added internationalization to the React Native app!
The full source code is available in this repo.
32