FormatJS in React Native: Streamline your I18N Workflow

FormatJS in React Native: Streamline your I18N Workflow

FormatJS is a set of tools to internationalize your project. Usually, using popular i18n libraries, the workflow involves

  • creating a JSON translation file for each language​
  • giving each string to be translated a unique key to reference it by​
  • thinking of a meaningful structure for the JSON file​

Instead of manually fiddling with translation files (which can get cumbersome if your project supports a lot of languages), FormatJS uses a concept called Message Extraction to quickly generate a translation file, a process likely inspired by gettext to separate development from translation.
An extraction script aggregates all messages declared using FormatJS' API in the entire code base; a compile script commits back translations received from the translator into the codebase​.


Installation

Install FormatJS (formerly known as react-intl) along with its various tools: an ESLint plugin, a Babel plugin and CLI for message extraction. We also use expo-localization to easily access system locale variables. Feel free to use any other library that achieves this.

npm i react-intl babel-plugin-formatjs expo-localization
npm i -D @formatjs/cli eslint-plugin-formatjs 

For Android, you also need to enable the international variant of the built-in ICU i18n library (ICU - International components for Unicode is a library for Unicode and internationalization). Your build.gradle inside android/app should have an option to pick the JavaScriptCore flavor which you need to change to:

def jscFlavor = 'org.webkit:android-jsc-intl:+'

To integrate react-intl, wrap your app in the IntlProvider component and add a function to pick the correct translations according to the current locale like this:

import { NavigationContainer } from '@react-navigation/native';
import * as Localization from 'expo-localization';
import React, { FunctionComponent } from 'react';
import { IntlProvider } from 'react-intl';
import AppNavigator from './screens/AppNavigator';

const App: FunctionComponent = () => {
  const locale = Localization.locale;

  const translations = {
    en: require('./locales/en.json');
    de: require('./locales/de.json');
  }

  export const getCurrentTranslation: (
    locale: string
  ) =>
    | Record<string, string>
    | Record<string, MessageFormatElement[]>
    | undefined = (locale) => {
    const language = locale.split(/[-_]/)[0];
    const messages = translations[language] ?? translations['en']; //fallback
    return messages;
  };

  return (
    <IntlProvider messages={getCurrentTranslation(locale)} locale={locale} defaultLocale='en'>
      <NavigationContainer>
        <AppNavigator />
      </NavigationContainer>
    </IntlProvider>
  );
};

export default App;

Message Format

Messages in FormatJS use the ICU Message syntax to achieve all sorts of linguistic nuances regarding language specific conjugation, grammar, plural or dates.
Messages are declared in code using two different methods: React API and imperative API.

React API

You use the React API in tsx just as you would use any React Native component.

import { FormattedMessage } from 'react-intl';

<FormattedMessage
  description='Countdown HomeScreen'
  defaultMessage='Unlocked in {n} hours.'
  values={{n: time}}
/>

Note that the FormattedMessage component will compile into a plain string wrapped in <React.Fragment> so you will need to wrap it in a Text component. You can also use the textComponent prop of IntlProvider to set a different default text component.

Imperative API

When you can't use the React API message, e.g. in the title prop of a Button component, you can resort to the imperative API to declare messages:

import { useIntl } from 'react-intl';
// ...
const intl = useIntl();

return (
  <Button title={intl.formatMessage({
    description: 'Countdown HomeScreen',
    defaultMessage: 'Unlocked in {n} hours',
    values: {n: time},
  })}>
)

There are many more components, e.g. <FormattedDate>, <FormattedNumber> or <FormattedPlural>, (and their respective imperative API counter parts) to simplify localization of different types of strings.

Babel plugin

Upon adding messages such as above, you will probably get an error stating that your messages are missing an id prop or key. You could add identifiers manually but FormatJS actually recommends adding usage context to descriptions and have ids be generated automatically for reasons stated here. This is where the Babel plugin comes in. It can be used to achieve various things:

  • automatically inject ids
  • remove fields that are not used during runtime
  • verify ICU-compliance of messages

To use automatically generated Message ids, add this to your Babel configuration (see here for an overview of interpolation patterns):

{
  "plugins": [
    [
      "formatjs",
      {
        "idInterpolationPattern": "[sha512:contenthash:base64:6]",
        "ast": true
      }
    ]
  ]
}

This will inject ids into your message declarations into your transpiled code which are a hash of the description and the defaultMessage. The ast option pre-parses defaultMessage into AST format for better runtime performance.

Now you might be wondering: what if I want to use the same translation in different places in the code? Using traditional i18n frameworks, one would simply define a key for a translation and re-use the key in different files across the code base but now these keys are auto-generated and cannot be referenced. The authors of FormatJS discourage this re-use of translations because, as stated in the linked reasons above, messages are highly contextual. Depending on the context, the translated string might be the same in one language but different in another.

tl;dr: stick with individual translations even if you think they are the same.

Message Extraction

To extract messages, run the following command:

npx formatjs extract -- 'src/**/*.{js,jsx,ts,tsx}' --out-file extracted.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'

Make sure to use the same pattern as in your Babel config. This will generate a file called extracted.json containing all messages declared in the src folder, e.g.:

{
  "fi3/as": {
    "defaultMessage": "Unlocked in {n} hours",
    "description": "Countdown HomeScreen"
  }
}

FormatJS supports different translation file formats for various TMS.
You can even write custom formatters to modify the extracted file's format according to your needs.
Suppose we get the following de.json file back from our translator:

{
  "fi3/as": {
    "defaultMessage": "Freigeschaltet in {n} Stunden",
    "description": "Countdown HomeScreen"
  }
}

This file can then be compiled back into our code base using the following command (assuming you run it from your project's root directory):

npx formatjs compile /path/to/file/de.json --out-file src/locales/de.json

By default, the only modification the compile script applies to the translation file is removing descriptions. Additional modifications that can be configured include using the --format option to supply yet again a custom formatter or using the --ast option to pre-compile messages into AST format for better runtime performance.

Finally, add the scripts to your package.json :

{
  "scripts": {
    "extract-msgs": "formatjs extract 'src/**/*.{js,jsx,ts,tsx}' --out-file extracted.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'", 
    "compile-msgs": "formatjs compile-folder ./translations ./src/locales"
  }
}

We use the compile-folder to bulk compile all files in the ./translations folder.


Conclusion

FormatJS is a fantastic tool for those that are annoyed with the workflow of traditional i18n frameworks. From our experience, there are more decisions to be made regarding setup of workflows. For instance, message extraction comes with the need to manually diff the extracted translation against existing translations to let the translator know which messages are new. Currently, FormatJS supports various TMSs which are all cloud-based and paid. Our search for a simple translation editor with support for FormatJS has been unsuccessful so far. You'll likely need to have your translator edit JSON files (in which case you need to decide on the format of the extracted translation file). Maybe there are other clever solutions out there? We'll let you know once we have found something 😁 .