Logo
BEAUTY IN SIMPLE
All Posts

React Native Internationalization with i18next

rn-i18n

What are we going to build?

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

Intro

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”.

init screen

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.

Content

1. Install and configure react-i18next library

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>
  );
}

2. Add translations (Google Sheet + automated script)

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:

google sheet 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:

google sheet translations

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.

translations folder

To be able to use these translations in our React Native project, we need to:

  1. 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";
  1. 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:

demo1

3. Custom plugin to store chosen language in the local storage

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!

- Install @react-native-async-storage/async-storage library

  • 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

- Install expo-localization library

expo install expo-localization

For React Native CLI or expo bare React Native app also follow these additional installation instructions.

- Write languageDetectorPlugin

//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 };

- Update i18n config file

//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.