A React i18n Tutorial with FormatJS

Get to know the most prominent features of the FormatJS library, and follow this step-by-step tutorial to see how they work in practice for React i18n.

FormatJS, formerly react-intl, is a library that has been repackaged to offer integration with Vue.js. In this React i18n tutorial, we will rework an existing demo app for a fictional grocery delivery startup into FormatJS to see how its core features work in practice.

Library requirements

Make sure you take care of the following steps to install the libraries in this tutorial if you want to follow along:

Install CRA with Typescript:

npx create-react-app my-app --template typescript

Install react-intl and the supporting libraries:

npm i -S react-intl bulma

The basics of FormatJS

FormatJS is a collection of libraries allowing you to include i18n features and l10n workflows in a web project. It uses the ICU Message syntax for defining the translation messages. If you want to take a look at ICU, check out The Missing Guide to the ICU Message Format. For reference, the ICU Message format is a standardized way of encoding basic messages, interpolation, and general selection, based on values. Here are some examples:

{
"Hello": "Hello World",
"Argument": "Hello {username}",
"Plural": "{n, plural, one {An Example} other {# Examples}}",
"Date": "Days left {days, date, medium}",
"Gender": "{gender, select,
  male {He}
  female {She}
  other {They}
 } send you a message"
}

Using this format, we define messages as key-value pairs. You define parameters using brackets {variable}, plurals using {n, plural}, gender as {gender, select} and dates using {days, date, medium}.

In its basic offering, FormatJS provides the IntlMessageFormat object that you can use directly for formating messages. Here is an example of how to use plural formatting running in Node.js:

index.js

const IntlMessageFormat = require('intl-messageformat').default

console.log(
new IntlMessageFormat(
`{n, plural, one {An Example} other {# Examples}}`,
'en-US'
).format({n: 1})
) // => An Example

console.log(
  new IntlMessageFormat(
  `{n, plural, one {An Example} other {# Examples}}`,
  'en-US'
).format({n: 100})
)// => 100 Examples

This is useful for formatting strings in plain JavaScript applications or simple pages. You can find the reference options for this object in the official docs. Let us take a look now at how to use FormatJS in more complex scenarios.

Our demo application

For the purposes of this tutorial, we will reuse a demo app developed for The Ultimate Guide to React Localization with i18next. The app is about a fictional farm startup that sends customers a weekly basket of organic produce. Feel free to refer to it if you want to find out how to use react-i18next.

🗒 Note » Get the full code of our demo app straight from GitHub.

In this tutorial, instead of using JavaScript and i18next, we are using Typescript and FormatJS so you can have a better idea of how you can adopt those technologies together. Shall we start?

Setting up the project

First, we replace the boilerplate code with our own components, starting with the <App.tsx/>.

App.tsx

import React from "react";

import Navbar from "./components/Navbar";
import Header from "./components/Header";
import WeeklyBasket from "./components/WeeklyBasket";
import "./App.scss";

interface AppProps {}
const App: React.FC<AppProps> = () => {
  return (
    <>
      <Navbar />
      <main role="main" className="pt-5 px-3">
        <Header/>
        <WeeklyBasket />
      </main>
    </>
  );
}
export default App;

Here, we import all the main components (Navbar, Header, WeeklyBasket) into the main app. Next, we define each component as follows:

<Navbar.tsx/> is a relatively basic Bulma navbar. It contains the navigational bar, the logo, and the links to the Weekly Basket page.

Navbar.tsx

import React from "react";

import logo from "../images/logo.png";

interface NavbarProps {
}

const Navbar: React.FC<NavbarProps> = () => {
  return (
    <nav
      className="navbar"
      role="navigation"
      aria-label="main navigation"
    >
      <div className="navbar-brand">
        <a className="navbar-item" href="/">
          <img className="navbar-logo" src={logo} alt="logo"/>
          <strong>Grootbasket</strong>
        </a>
      </div>
      <div className="navbar-menu">
        <div className="navbar-start">
          <a className="navbar-item" href="/">
            Weekly Basket
          </a>
        </div>
      </div>
    </nav>
  );
}
export default Navbar;

The header component is defined as a div with some text:

Header.tsx

import React from "react";

interface HeaderProps {
}

const Header: React.FC<HeaderProps> = () => {
  return (
    <div className="header">
      <h1 className="title is-4 has-text-centered mb-5">
        In this Week's Grootbasket — 21 Jul 2021
      </h1>
      <p>2,101 baskets delivered</p>
    </div>
  );
}
export default Header;

The WeeklyBasket component loads a list of shopping items from a local file named data.json. You will need to create this file inside the public folder and leave it empty for now.

WeeklyBasket.tsx

import React, { useState, useEffect } from "react";
import Item from "./Item";
import ItemModel from "../models/ItemModel";

interface WeeklyBasketProps {}
const WeeklyBasket: React.FC<WeeklyBasketProps> = () => {
  const [items, setItems] = useState<ItemModel[]>([]);

  useEffect(() => {
    fetch("/data.json")
      .then((response) => response.json())
      .then((json) => setItems(json));
  }, []);

  if (items.length === 0) {
    return <p>Loading...</p>;
  } else {
    return (
      <div className="columns is-multiline">
        {items.map((item) => (
          <Item key={item.id} {...item} />
        ))}
      </div>
    );
  }
}

export default WeeklyBasket;

Each item on the  WeeklyBasket will be mapped as an Item component. You will need to define the following properties for the item in data.json: idtitle, imageUrl for the image, description and estimatedWeightInKilograms:

Item.tsx

import React from "react";
import ItemModel from "../models/ItemModel";

type ItemProps = Exclude<ItemModel, 'id'>

const Item: React.FC<ItemProps> = ({
                                     title,
                                     imageUrl,
                                     description,
                                     estimatedWeightInKilograms,
                                   }) => {
  return (
    <div className="column is-one-third">
      <div className="card">
        <div className="card-image">
          <figure className="image">
            <img src={imageUrl} alt={title}/>
          </figure>
        </div>

        <div className="card-content">
          <p className="title is-5 mb-2">{title}</p>

          <div className="content">
            <p className="mb-1">{description}</p>

            <p>
              <span className="tag is-light is-medium">
                Estimated weight
              </span>{" "}
              {estimatedWeightInKilograms}
              kg
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}

export default Item;

The item model is an interface that contains the shopping basket product:

models/ItemModel.ts

export default interface ItemModel {
  id: string;
  title: string;
  imageUrl: string;
  description: string;
  estimatedWeightInKilograms: number;
}

Feel free to add a mock JSON file with some fake ItemModels. You should be able to see the following screen when you start the app:

We’re now ready to localize the app.

Setting up FormatJS

Basic messages

FormatJS comes with a list of helper functions, React providers and hooks, as well as Vue adapters to help localize the application. You can start by injecting the IntlProvider in the <App.tsx/>.

App.tsx

import React from "react";
import {IntlProvider} from 'react-intl'

import Navbar from "./components/Navbar";
import Header from "./components/Header";
import WeeklyBasket from "./components/WeeklyBasket";
import "./App.scss";

const messages = {
  "app_name": "Φρουτοκάλαθος",
  "weekly_basket_link": "Εβδομαδιαίο καλάθι"
}
const lang = 'el';

interface AppProps {}
const App: React.FC<AppProps> = () => {
  return (
    <IntlProvider messages={messages} key={lang} locale={lang} defaultLocale="en">
      <Navbar />
      <main role="main" className="pt-5 px-3">
        <Header/>
        <WeeklyBasket />
      </main>
    </IntlProvider>
  );
}
export default App;

Here, we define some messages in Greek, and we specify the default locale and current locale properties. We then need to go to each page and replace the hard coded text with instances of intl.formatMessage. Here is an example for the <Navbar.tsx/> component:

Navbar.tsx

import React from "react";

import logo from "../images/logo.png";
import {useIntl} from "react-intl";

interface NavbarProps {
}

const Navbar: React.FC<NavbarProps> = () => {
  const intl = useIntl();
  return (
    <nav
      className="navbar"
      role="navigation"
      aria-label="main navigation"
    >
      <div className="navbar-brand">
        <a className="navbar-item" href="/">
          <img className="navbar-logo" src={logo} alt="logo"/>
          <strong>{intl.formatMessage({
            id: "app_name",
            defaultMessage: "Grootbasket"
          })}</strong>
        </a>
      </div>
      <div className="navbar-menu">
        <div className="navbar-start">
          <a className="navbar-item" href="/">
            {
              intl.formatMessage({
                id: "weekly_basket_link",
                defaultMessage: "Weekly Basket"
              })}
          </a>
        </div>
      </div>
    </nav>
  );
}
export default Navbar;

We have used the useIntl() hook that is available in the context to retrieve the current instance of the i18n helper object. This object exposes the react-intl functions such as formatMessage, formatTime or formatDate. You can find the full list of exposed methods here. In the component, we provided id parameters for each message as well as a default message.

Here is how the Navbar will look like when you reload the application.

Plurals

Feel free to replace the remaining text with calls to relevant methods. Here is an example of using plurals for the message on baskets delivered:

We change the <Header /> text to the following:

Header.tsx

<p>{
    intl.formatMessage({
    id: "baskets_delivered",
    defaultMessage: "{itemCount} baskets delivered",
    }, {itemCount: 2001})
    }
</p>

The defaultMessage accepts an itemCount parameter with a default value of 2001.

Then update the messages in App.tsx:

const messages = {
  "app_name": "Φρουτοκάλαθος",
  "weekly_basket_link": "Εβδομαδιαίο καλάθι",
  "baskets_delivered": "{itemCount, plural, =0 {no δεν παραδόθηκαν καλάθια} one {# καλάθι παραδόθηκε} other {# καλάθια παραδόθηκαν} }"
}

Here, we use the plural form of the messages. We need to provide cases for each count: =0 is for none, one is for one basket, and any other number for more than one basket. Please note that other languages may handle plurals in another way.

You should now be seeing the interpolated text in the app:

Let’s see next how to add further languages and switch between them.

Implementing a language switcher

We often want our users to be able to change our app’s language themselves. We define a <LanguageSwitcher/> component for that role. We use a select component with an onChange handler that accepts a callback when the user selects a locale:

LanguageSwitcher.tsx

import React from "react";
import {useIntl} from "react-intl";

interface LanguageSwitcherProps {
  onChangeLanguage?: (lang: string) => void;
}

const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({onChangeLanguage}) =>{
  const intl = useIntl();
  return (
    <div className="select">
      <select
        value={intl.locale}
        onChange={(e) => {
          if (onChangeLanguage) {
            onChangeLanguage(e.target.value)
          }
        }
        }
      >
        <option value="en">English</option>
        <option value="el">Greek</option>
      </select>
    </div>
  );
}
export default LanguageSwitcher;

Here, we need to propagate the language selection to the parent component. The reason: If we want to change the locale with react-intl, we need to re-render the IntlProvider again with different messages and a different locale parameter. The parent component is the Navbar, which will propagate the onChangeLanguage prop to its parent component, App.tsx:

Navbar.tsx

import React from "react";
import LanguageSwitcher from "./LanguageSwitcher";
// ...
interface NavbarProps {
  onChangeLanguage: (lang: string) => void
}

const Navbar: React.FC<NavbarProps> = ({onChangeLanguage}) => {
  // ...
  return (
    <nav>
      {/* ... */}
      <div className="navbar-menu">
        {/* ... */}
        <div className="navbar-end">
          <div className="navbar-item">
            <LanguageSwitcher onChangeLanguage={onChangeLanguage}/>
          </div>
        </div>
      </div>
    </nav>
  );
}
export default Navbar;

Then, in the App.tsx we handle the language change:

App.tsx

import React, {useState} from "react";
import {IntlProvider} from 'react-intl'
// ...
import i18n from "./services/i18n";

interface AppProps {}
const App: React.FC<AppProps> = () => {
  const [lang, setLang] = useState(i18n.getDefaultLocale())
  const onChangeLanguage = (locale: string) => {
    setLang(locale);
  }
  const messages = i18n.getMessages(lang);
  return (
    <IntlProvider messages={messages} key={lang} locale={lang} defaultLocale={i18n.getDefaultLocale()}>
     {/* ... */}

At this point, we are introducing a i18n service. This is just a class that encapsulates the methods and config for the locale messages, default locale, and available locales. You will need to create this file inside the services folder:

services/i18n.ts

import intlMessagesEL from '../locales/el.json';
import intlMessagesEN from '../locales/en.json';

type i18nConfig = {
  defaultLocale: string;
  availableLocales: string[]
}

const messagesMap: Record<string, any> = {
  'el': intlMessagesEL,
  'en': intlMessagesEN
}

class LocaleService {
  private readonly defaultLocale: string;
  private readonly availableLocales: string[];
  constructor(config: i18nConfig) {
   this.defaultLocale = config.defaultLocale;
   this.availableLocales = config.availableLocales
  }

  getAvailableLocales() {
    return this.availableLocales;
  }

  getDefaultLocale() {
    return this.defaultLocale;
  }

  getMessages(lang: string) {
    if (this.availableLocales.includes(lang)) {
      return messagesMap[lang];
    }
    return messagesMap[this.defaultLocale];
  }
}

export default new LocaleService({defaultLocale: 'el', availableLocales: ['el', 'en']});

Let us define now some properties for the defaultLocale, availableLocales and some respective methods to retrieve the messages from a particular locale.

Loading messages

The only thing that is missing is those two imports: intlMessagesEL and intlMessagesEN. You can create two files inside the src/locales folder for each locale. Here is an example for the Greek translations:

/locales/el.json

{
  "app_name": "Φρουτοκάλαθος",
  "baskets_delivered": "{itemCount, plural, =0 {no δεν παραδόθηκαν καλάθια} one {# καλάθι παραδόθηκε} other {# καλάθια παραδόθηκαν}}",
  "weekly_basket_link": "Εβδομαδιαίο Καλάθι"
}

You can now reload the app to see the language switcher in action:

Asynchronous (lazy) loading of translation files

For scalability and performance, we want to load the translation file(s) associated with the currently active language. When we switch locales, we want to request the translation files from the backend on demand.

Compared to i18next, FormatJS does not provide a mechanism for lazy loading of translation files, so we will have to be more resourceful. We can leverage the fact that we can lazy load our modules using WebPack code spliting. First, we will have to import the babel syntax for this:

> npm i babel-plugin-syntax-dynamic-import -D

Then, include the plugin in the package.json section:

package.json

...
"babel": {
    "presets": [
      "react-app"
    ],
    "plugins": ["@babel/plugin-syntax-dynamic-import"]
  }
}
...

So, now we will need to modify the i18n.ts service we created earlier to dynamically load the translation files. Here is what it will look like:

src/services/i18n.ts

type i18nConfig = {
  defaultLocale: string;
  availableLocales: string[]
}

class LocaleService {
  ...
  async getMessages(lang: string) {
    if (this.availableLocales.includes(lang)) {
      let messages = null;
      try {
        messages = await this.loadMessages(lang);
      } catch (e) {
        console.error(e);
      }
      return messages;
    }
  }

  loadMessages(lang: string) {
    return import( `../locales/${lang}.json`);
  }
}

export default new LocaleService({defaultLocale: 'el', availableLocales: ['el', 'en']});

Instead of storing the messages in the messagesMap, we use async await keywords to load the JSON files on demand. The getMessages method now returns a promise so we will need to modify our App.tsx code to handle this async effect. Here is what it look like:

...
interface AppProps {}
const App: React.FC<AppProps> = () => {
  const [lang, setLang] = useState(i18n.getDefaultLocale())
  const [messages, setMessages] = useState();
  const onChangeLanguage = (locale: string) => {
    setLang(locale);
  }

  useEffect(() => {
    i18n.getMessages(lang).then(data => {
      setMessages(data)
    });
  }, [lang])

  return (
    ...
  );
}
export default App;

We include a userEffect that triggers the call to getMessages and updates the local state via a useState hook. Once the component loads and whenever we switch locales, the effect is called again with the new lang parameter. An extra benefit is that the modules are cached, so there will be only one network request per locale if you inspect the networks tab:

Date formatting

For date formatting, we can use either the providedFormattedDate component or the intl.formatDate method as offered by the hook instance. Here is how we can use them in the “Weekly basket” header of our app:

src/components/Header.tsx

import React from "react";
import {useIntl} from "react-intl";

interface HeaderProps {
}
const Header: React.FC<HeaderProps> = () => {
  const intl = useIntl();
  return (
    <div className="header">
      <h1 className="title is-4 has-text-centered mb-5">
        {
          intl.formatMessage({
            id: "weekly_basket_title",
            defaultMessage: "In this Week's Grootbasket — {today, date, medium}",
          }, {
            today: new Date()
          })
        }
      </h1>
      ...
    </div>
  );
}
export default Header;

/locales/el.json

... 
"weekly_basket_title": {
   "defaultMessage": "Σε αυτήν την εβδομάδα στο Grootbasket — {today, date, medium}"
}

/locales/en.json

"weekly_basket_title": {
   "defaultMessage": "During this week at Grootbasket — {today, date, medium}"
 }

You should then be able to see the translation:

Number formatting

ICU Message strings can handle number formatting as well. For example, for the estimated weight message, we can use the following format:

Intl.NumberFormat(locale, {
style: 'unit',
unit: 'kilogram'
}).format(value)

Here is how we can use it in the Item component to translate the weight of each product:

src/components/Item.tsx

import React from 'react';
import ItemModel from '../models/ItemModel';
import { useIntl } from 'react-intl';

function getWeightFormatters() {
  return {
    getNumberFormat: (locale, opts) => {
      return new Intl.NumberFormat(locale, { style: 'unit', unit: 'kilogram' });
    },
    getDateTimeFormat: (locale, opts) => new Intl.DateTimeFormat(locale, opts),
    getPluralRules: (locale, opts) => new Intl.PluralRules(locale, opts)
  };
}

type ItemProps = Exclude<ItemModel, 'id'>

const Item: React.FC<ItemProps> = ({
                                     title,
                                     imageUrl,
                                     description,
                                     estimatedWeightInKilograms
                                   }) => {

  const intl = useIntl();
  return (
    <div className="column is-one-third">
      ...
      <div className="content">
        ...
        <p>
              <span className="tag is-light is-medium">
                {
                  intl.formatMessage({
                    id: 'estimated_weight_kg',
                    defaultMessage: `Estimated weight {weight, number}`
                  }, {
                    weight: estimatedWeightInKilograms
                  }, {
                    formatters: getWeightFormatters()
                  })
                }
              </span>
          ...
          );
          }

          export default Item;

/locales/el.json

"estimated_weight_kg": {
  "defaultMessage": "Εκτιμώμενο βάρος {weight, number}"
}

/locales/en.json

"estimated_weight_kg": {
   "defaultMessage": "Estimated Weight {weight, number}"
}

Since we want to format a number using weights, we have implemented a custom formatter that specifically uses the kilogram unit options. The end result is the following:

Given the current setup, we should be able to see the translated website locally; Let us move on now with automating the process of declaring, extracting, and compiling the translation messages using the FormatJS CLI tool.

Extracting messages

FormatJS also considers the typical development workflow for managing translation strings. Currently, we have only a few strings in our setup. If we were to work on a bigger project, we would have to manage those string updates and also add new messages. In that situation, it’s best if you had tools that automate the process of extracting messages for translation. Fortunately, FormatJS offers a CLI tool for that workflow.

First, you will need to install the CLI tool:

npm i -S @formatjs/intl

Then add the following npm targets in the package.json:

package.json

"extract": "formatjs extract",
"compile": "formatjs compile"

The two commands are extract, for extracting the available messages from the source code, and compile, for creating the message catalog for usage in our application after the translation process.

Next, you can run the extract command to extract all messages from the source files into a JSON file.

For example, the following command extracts the definitions from the source files and exports them in a single file:

npm run extract -- "src/**/*.{ts,tsx}" --out-file el.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'

The interpolation pattern parameter is used to add unique ids to tags in case you didn’t provide one.

This creates a file with the following content:

el.json

{
  "app_name": {
    "defaultMessage": "Grootbasket"
  },
  "baskets_delivered": {
    "defaultMessage": "{itemCount, plural, =0 {no no baskets delivered} one {# basket delivered} other {# baskets delivered} }"
  },
  "weekly_basket_link": {
    "defaultMessage": "Weekly Basket"
  }
}

Here, you need to perform translation for the Greek language by sending it to a translation management system (TMS), software designed specifically for managing the localization and translation of language assets. You can also create multiple copies of the same file for each locale you support. Once you have performed the translation, you need to import the messages into the system by compiling them into a format that react-intl understands.

For example, let us translate the messages from this file:

{
  "app_name": {
    "defaultMessage": "Φρουτοκάλαθος"
  },
  "baskets_delivered": {
    "defaultMessage": "{itemCount, plural, =0 {no δεν παραδόθηκαν καλάθια} one {# καλάθι παραδόθηκε} other {# καλάθια παραδόθηκαν} }"
  },
  "weekly_basket_link": {
    "defaultMessage": "Εβδομαδιαίο Καλάθι"
  }
}

You run the compile task using this file as input and sending it to a file inside the locales folder:

npm run compile -- el.json --ast --out-file src/locales/el.json

As soon as you have performed all those steps, you should reload the app and see the translations work as before. This time, though, you automated a part of the process of extracting and compiling all messages.

You can also compile the messages to send them to a TMS. For example, FormatJS allows to do that using the simple formatter. For that purpose, you will need to have both the extract and compile tasks use the same format parameter:

npm run extract -- "src/**/*.{ts,tsx}" --out-file el.json --id-interpolation-pattern '[sha512:contenthash:base64:6]' --format simple

npm run compile -- el.json --ast --out-file src/locales/el.json --format simple

This will create a simpler representation of the messages. This format is simpler and can be easily consumed by localization solutions.

{
  "app_name": "Grootbasket",
  "baskets_delivered": "{itemCount, plural, =0 {no no baskets delivered} one {# basket delivered} other {# baskets delivered} }",
  "weekly_basket_link": "Weekly Basket"
}

Final thoughts on React i18n with FormatJS

Mission complete, well done! Now that you have laid the groundwork, make sure to check out Phrase, the leanest, fastest, and most reliable software localization platform on the market. Designed specifically for product teams, it will do the heavy lifting in your localization process so you can stay focused on the code you love so much. Phrase features a robust CLI and API, GitHub, Bitbucket, and GitLab sync, and a great web console for translators. Check out all Phrase features today, and give it a try with a 14-day free trial.

5 (100%) 59 votes
Comments
close

Untangle Continuous Localization With Ease

Get your own FREE EBOOK copy now to explore

  • advanced automation workflows
  • rapid release cycles,
  • simultaneous translation and delivery,
  • new ways of testing your localized product