Internationalization of React apps
In this tutorial, we'll learn how to add internationalization (i18n) to an existing React JS application.
Let's Start
We're going to translate the following app:
import React from "react";
import { render } from "react-dom";
import Inbox from "./Inbox";
const App = () => <Inbox />;
render(<App />, document.getElementById("root"));
import React from "react";
export default function Inbox() {
const messages = [{}, {}];
const messagesCount = messages.length;
const lastLogin = new Date();
const markAsRead = () => {
alert("Marked as read.");
};
return (
<div>
<h1>Message Inbox</h1>
<p>
See all <a href="/unread">unread messages</a>
{" or "}
<a onClick={markAsRead}>mark them</a> as read.
</p>
<p>
{messagesCount === 1
? `There's ${messagesCount} message in your inbox.`
: `There are ${messagesCount} messages in your inbox.`}
</p>
<footer>Last login on {lastLogin.toLocaleDateString()}.</footer>
</div>
);
}
As you can see, it's a simple mailbox application with only one page.
Installing LinguiJS
Follow setup guide either for projects using LinguiJS with Create React App or for general React projects.
Setup
We will directly start translating the Inbox
component, but we need to complete one more step to setup our application.
Components need to read information about current language and message catalogs from i18n
instance. Initially, you can use the one created and exported from @lingui/core
and later you can replace with your one if such need arises.
Lingui uses the I18nProvider
to pass the instance i18n
to your React components.
Let's add all required imports and wrap our app inside I18nProvider
:
import React from "react";
import { render } from "react-dom";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { messages } from "./locales/en/messages";
import Inbox from "./Inbox";
i18n.load("en", messages);
i18n.activate("en");
const App = () => (
<I18nProvider i18n={i18n}>
<Inbox />
</I18nProvider>
);
render(<App />, document.getElementById("root"));
You might be wondering: how are we going to change the active language? That's what the I18n.load
and i18n.activate
calls are for! However, we cannot change the language unless we have the translated message catalog. And to get the catalog, we first need to extract all messages from the source code.
Let's deal with language switching later... but if you're still curious, take a look at example with Redux and Webpack.
Introducing internationalization
Now we're finally going to translate our app. Actually, we aren't going to translate from one language to another right now. Instead, we're going to prepare our app for translation. This process is called internationalization and you should practice saying this word aloud until you're able to say it three times very quickly.
From now on, internationalization will be shortened to a common numeronym i18n.
Let's start with the basics - static messages. These messages don't have any variables, HTML or components inside. Just some text:
<h1>Message Inbox</h1>
All we need to make this heading translatable is wrap it in Trans
macro:
import { Trans } from "@lingui/macro";
<h1>
<Trans>Message Inbox</Trans>
</h1>;
Macros vs. Components
If you're wondering what macros are and what's the difference between macros and components, this short paragraph is for you.
In general, macros are executed at compile time and they transform source code in some way. We use this feature in LinguiJS to simplify writing messages.
Under the hood, all JSX macros are transformed into Trans
component. Take a look at this short example. This is what we write:
import { Trans } from "@lingui/macro";
<Trans>Hello {name}</Trans>;
And this is how the code is transformed:
import { Trans } from "@lingui/react";
<Trans id="OVaF9k" message="Hello {name}" values={{ name }} />;
See the difference? Trans
component receives id
and message
props with a message in ICU MessageFormat syntax.
We could write it manually, but it's just easier and shorter to write JSX as we're used to and let macros generate the message for us.
Another advantage of using macros is that all non-essential properties are excluded from the production build. This results in a significant reduction in the size footprint for internationalization.
// NODE_ENV=production
import { Trans } from "@lingui/react";
<Trans id="OVaF9k" values={{ name }} />;
Extracting messages
Back to our project. It's nice to use JSX and let macros generate messages under the hood. Let's check that it actually works correctly.
All messages from the source code must be extracted into external message catalogs. Message catalogs are interchange files between developers and translators. We're going to have one file per language. Let's enter command line for a while.
We're going to use CLI again. Run extract
command to extract messages:
> lingui extract
Lingui was unable to find a config!
Create 'lingui.config.js' file with LinguiJS configuration in root of your project (next to package.json). See https://lingui.dev/ref/conf
We need to create the lingui.config.js
file:
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: ["cs", "en"],
catalogs: [
{
path: "<rootDir>/src/locales/{locale}/messages",
include: ["src"],
},
],
};
After adding the configuration file, let's run extract
command again:
> lingui extract
Catalog statistics:
┌──────────┬─────────────┬─────────┐
│ Language │ Total count │ Missing │
├──────────┼─────────────┼─────────┤
│ cs │ 1 │ 1 │
│ en │ 1 │ 1 │
└──────────┴─────────────┴─────────┘
(use "lingui extract" to update catalogs with new messages)
(use "lingui compile" to compile catalogs for production)
Nice! It seems it worked, we have two message catalogs (one per each locale) with 1 message each. Let's take a look at file src/locales/cs/messages.po
:
msgid ""
msgstr ""
"POT-Creation-Date: 2021-07-22 21:44+0900\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: cs\n"
#: src/Inbox.js:12
msgid "Message Inbox"
msgstr ""
That's the message we've wrapped inside Trans
macro!
Let's add the Czech translation:
#: src/Inbox.js:12
msgid "Message Inbox"
msgstr "Příchozí zprávy"
If we run extract
command again, we'll see that all Czech messages are translated:
> lingui extract
Catalog statistics:
┌──────────┬─────────────┬─────────┐
│ Language │ Total count │ Missing │
├──────────┼─────────────┼─────────┤
│ cs │ 1 │ 0 │
│ en │ 1 │ 1 │
└──────────┴─────────────┴─────────┘
(use "lingui extract" to update catalogs with new messages)
(use "lingui compile" to compile catalogs for production)
That's great! So, how we're going to load it into your app? LinguiJS introduces concept of compiled message catalogs. Before we load messages into our app, we need to compile them. As you see in the help in command output, we use compile
for that:
> lingui compile
Compiling message catalogs…
Done!
What just happened? If you look inside locales/<locale>
directory, you'll see there's a new file for each locale: messages.js
. This file contains compiled message catalog.
If you use TypeScript, you can add --typescript
flag to compile
script to produce compiled message catalogs with TypeScript types.
Let's load this file into our app and set active language to cs
:
import React from "react";
import { render } from "react-dom";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { messages as enMessages } from "./locales/en/messages";
import { messages as csMessages } from "./locales/cs/messages";
import Inbox from "./Inbox";
i18n.load({
en: enMessages,
cs: csMessages,
});
i18n.activate("cs");
const App = () => (
<I18nProvider i18n={i18n}>
<Inbox />
</I18nProvider>
);
render(<App />, document.getElementById("root"));
When we run the app, we see the inbox header is translated into Czech.
Summary of basic workflow
Let's go through the workflow again:
- Add an
I18nProvider
, this component provides the active language and catalog(s) to other components - Wrap messages in
Trans
macro - Run
extract
command to generate message catalogs - Translate message catalogs (send them to translators usually)
- Run
compile
to create runtime catalogs - Load runtime catalog
- Profit
Steps 1 and 7 needs to be done only once per project and locale. Steps 2 to 5 become the common workflow for internationalizing the app.
It isn't necessary to extract/translate messages one by one. This usually happens in batches. When you finalize your work or PR, run extract
to generate latest message catalogs and before building the app for production, run compile
.
For more info about CLI, checkout the CLI tutorial.
Non-JSX Translation
So far we learned how to translate string inside a JSX element, but what if we want to translate something that is not inside a JSX? Or pass a translation as a prop to another component?
We have this piece of code in our example:
const markAsRead = () => {
alert("Marked as read.");
};
To translate it, we will use the useLingui
macro hook:
import { useLingui } from '@lingui/macro';
...
const { t } = useLingui();
const markAsRead = () => {
alert(t`Marked as read.`);
};
Now the Marked as read.
message would be picked up by extractor, and available for translation in the catalog.
You could also pass variables and use any other macro in the message.
const { t } = useLingui();
const markAsRead = () => {
const userName = "User1234";
alert(t`Hello {userName}, your messages marked as read!`);
};
Formatting
Let's move on to another paragraph in our project. This paragraph has some variables, some HTML and components inside:
<p>
See all <a href="/unread">unread messages</a>
{" or "}
<a onClick={markAsRead}>mark them</a> as read.
</p>
Although it looks complex, there's really nothing special here. Just wrap the content of the paragraph in Trans
and let the macro do the magic:
<p>
<Trans>
See all <a href="/unread">unread messages</a>
{" or "}
<a onClick={markAsRead}>mark them</a> as read.
</Trans>
</p>
Spooky, right? Let's see how this message actually looks in the message catalog. Run extract
command and take a look at the message:
See all <0>unread messages</0> or <1>mark them</1> as read.
You may notice that components and html tags are replaced with indexed tags (<0>
, <1>
). This is a little extension to the ICU MessageFormat which allows rich-text formatting inside translations. Components and their props remain in the source code and don't scare our translators. The tags in the extracted message won't scare our translators either: translators are used to seeing tags and their tools support them. Also, in case we change a className
, we don't need to update our message catalogs. How cool is that?
JSX to MessageFormat transformations
It may look a bit hackish at first sight, but these transformations are actually very easy, intuitive and feel very Reactish. We don't have to think about the MessageFormat, because it's created by the library. We write our components in the same way as we're used to and simply wrap text in the Trans
macro.
Let's see some examples with MessageFormat equivalents:
// Expressions
<p>
<Trans>Hello {name}</Trans>
</p>
// Hello {name}
Any expressions are allowed, not just simple variables. The only difference is, only the variable name will be included in the extracted message:
-
Simple variable -> named argument:
<p>
<Trans>Hello {name}</Trans>
</p>
// Hello {name} -
Any expression -> positional argument:
<p>
<Trans>Hello {user.name}</Trans>
</p>
// Hello {0} -
Object, arrays, function calls -> positional argument:
<p>
<Trans>The random number is {Math.rand()}</Trans>
</p>
// The random number is {0} -
Components might get tricky, but like we saw, it's really easy:
<Trans>
Read <a href="/more">more</a>.
</Trans>
// Read <0>more</0>.<Trans>
Dear Watson,
<br />
it's not exactly what I had in my mind.
</Trans>
// Dear Watson,<0/>it's not exactly what I had in my mind.
Obviously, you can also shoot yourself in the foot. Some expressions are valid and won't throw any error, yet it doesn't make any sense to write:
// Oh, seriously?
<Trans>{isOpen && <Modal />}</Trans>
If in doubt, imagine how the final message should look like.
Message ID
At this point we're going to explain what message ID is and how to set it manually.
Translators work with the message catalogs we saw above. No matter what format we use (gettext, xliff, json), it's just a mapping of a message ID to the translation.
Here's an example of a simple message catalog in Czech language:
Message ID | Translation |
---|---|
Monday | Pondělí |
Tuesday | Úterý |
Wednesday | Středa |
... and the same catalog in French language:
Message ID | Translation |
---|---|
Monday | Lundi |
Tuesday | Mardi |
Wednesday | Mercredi |
The message ID is what all catalogs have in common – Lundi and Pondělí represent the same message in different languages. It's also the same as the id
prop in Trans
macro.
There are two approaches to how a message ID can be created:
- Using the source language (e.g.
Monday
from English, as in example above) - Using a custom id (e.g.
weekday.monday
)
Both approaches have their pros and cons and it's not in the scope of this tutorial to compare them.
By default, LinguiJS generates message ID from the content of Trans
macro, which means it uses the source language. However, we can easily override it by setting the id
prop manually:
<h1>
<Trans id="inbox.title">Message Inbox</Trans>
</h1>
This will generate:
<h1>
<Trans id="inbox.title" message="Message Inbox" />
</h1>
In our message catalog, we'll see inbox.title
as message ID, but we also get Message Inbox
as default translation for English.
For the rest of this tutorial, we'll use auto-generated message IDs to keep it simple.
Plurals
Let's move on and add i18n to another text in our component:
<p>
{messagesCount === 1
? `There's ${messagesCount} message in your inbox.`
: `There are ${messagesCount} messages in your inbox.`}
</p>
This message is a bit special, because it depends on the value of the messagesCount
variable. Most languages use different forms of words when describing quantities - this is called pluralization.
What's tricky is that different languages use different number of plural forms. For example, English has only two forms - singular and plural - as we can see in the example above. However, Czech language has three plural forms. Some languages have up to 6 plural forms and some don't have plurals at all!
Lingui uses Intl.PluralRules
which is supported in every modern browser and can be polyfilled for older. So you don't need to setup anything special.
English plural rules
How do we know which plural form we should use? It's very simple: we, as developers, only need to know plural forms of the language we use in our source. Our component is written in English, so looking at English plural rules we'll need just two forms:
one
Singular form
other
Plural form
We don't need to select these forms manually. We'll use Plural
component, which takes a value
prop and based on the active language, selects the right plural form:
import { Trans, Plural } from "@lingui/macro";
<p>
<Plural value={messagesCount} one="There's # message in your inbox" other="There are # messages in your inbox" />
</p>;
This component will render There's 1 message in your inbox
when messageCount = 1
and There are # messages in your inbox
for any other values of messageCount
. #
is a placeholder, which is replaced with value
.
Cool! Curious how this component is transformed under the hood and how the message looks in MessageFormat syntax? Run extract
command and find out by yourself:
In the catalog, you'll see the message in one line. Here we wrapped it to make it more readable.
The Plural
is gone and replaced with Trans
again! The sole purpose of Plural
is to generate proper syntax in message.
Things are getting a bit more complicated, but i18n is a complex process. At least we don't have to write this message manually!