Note: Do you have an app in single language? Keep reading! Things like pluralization, number and date formatting are still useful even for monolingual projects.

Internationalization is a perfect way to make you project accessible for wider audience.

react-intl is still a standard when it comes to i18n of React apps. Even though it haven’t been maintained for about a year, the community is so strong that it’s gaining on popularity and new independent tools are constantly being published.

I would like to introduce you to alternative i18n solution I’ve been working on for the last year and a half — LinguiJS. The low-level API is very similar to react-intl to make migration of my projects easier, but on top of that there’s a completely different developer experience.

Brief introduction of LinguiJS

I started LinguiJS as an experiment how i18n could be simplified using Babel. I spent hours debugging syntax errors in message syntax and another significant amount of time building tools which were missing. After few iterations I got a library which is very similar to react-intl , but optimized under the hood with several features react-intl lacks.

There aren’t many projects who publicly announced that they’re using LinguiJS, but the library has already a lot of excited users. The latest project added to LinguiJS showroom is MyMusicTaste, a TypeScript app translated to 13 languages.

Let’s proceed to the tutorial. I’ll assume zero knowledge about i18n and start from the beginning:

What is internationalization

W3C has a beautiful definition which I use all the time:

Internationalization is the design and development of a product, application or document content that enables easy localization for target audiences that vary in culture, region, or language. — W3C Web Internationalization FAQ

What does it mean? When you develop an app and you want it to be available in more than one language, you need to find a way how to easily swap translations of texts. You should be able to do it without altering your code, but simply by providing different dictionary with all messages. Dictionaries are translated by translators with very minimum or non at all programming skills.

In other words: internationalization is what you do as a developer to allow translators to localize your app into multiple languages.

Step 1 — Installation

Let’s start with installing required tools. LinguiJS comes with useful CLI which we can use to install all required packages:

npm install -g @lingui/cli

lingui init

If you don’t trust it, simply run lingui init --dry-run to inspect what it’ll install.

The command install @lingui/core and @lingui/react . The first package contains all i18n functionality and can be used in any project. The second package provides React components build on top of core functionality, mostly to take advantage of React lifecycle methods and optimize re-rendering.

It also installs @lingui/babel-preset-react which we need to add to our .babelrc config:

{

"presets": [

"env",

"react",

"@lingui/babel-preset-react"

]

}

Note: This babel preset is completely optional, but recommended. XXX

Step 2 — Prepare the application

First we need to be able to load messages from external dictionary and set the language we’re going to use in our app. This is the job for I18nProvider . You need it only once and it should be the top-most component in your app (It’s similar to Provider from react-redux or ApolloProvider from react-apollo).

// index.js

import React from 'react'

import { render } from 'react-dom'

import Inbox from './Inbox.js'



import { I18nProvider } from '@lingui/react' const catalogs = {} // don't worry about it now const App = () => (

<I18nProvider language="en" catalogs={catalogs}>

<Inbox />

</I18nProvider>

)



render(<App />, document.getElementById('app'))

Since we don’t have any dictionaries (also called message catalogs), let’s leave catalogs prop empty for now. We’ll get back later. I18nProvider sets the i18n context which we’ll use next.

Step 3 — Internationalizing

Here comes the interesting part. We’ll take our simple application and turn it into an app which can be translated. This is our app:

const Inbox = ({ messages, markAsRead, user }) => {

const messagesCount = messages.length

const { name, lastLogin } = user



return (

<div>

<h1>Message Inbox</h1>



<p>

See all <Link to="/unread">unread messages</Link>{" or "}

<a onClick={markAsRead}>mark them</a> as read.

</p>



<p>

{

messagesCount === 1

? "There's {messagesCount} message in your inbox."

: "There're {messagesCount} messages in your inbox."

}

</p>



<footer>

Last login on {lastLogin}.

</footer>

</div>

)

}

Let’s say we want to translate the heading, Message Inbox. We need to wrap it into component, which loads the translation from dictionary.

LinguiJS uses <Trans> component for simple messages. All we need to do is wrap our message inside this component:

<h1><Trans>Message Inbox</Trans></h1>

How does it work? <Trans> component will lookup Message Inbox in dictionary or replace it with translation. This is the default approach, when we use messages as message IDs. Message ID is what identifies our message in dictionaries. For example, Czech dictionary would look like this:

{

"Message Inbox": "Příchozí pošta"

}

Message ID is defined by developer (in source code), the translation by translator (in dictionary).

Messages or keys as message IDs?

You’ve might heard that using messages as IDs is an anti-pattern and is better to use custom keys, like this:

<h1><Trans id="Inbox.message">Message Inbox</Trans></h1>

Then, the dictionary would look like this:

{

"Inbox.message": "Příchozí pošta"

}

I won’t argue which one is better. I’ve been working on large projects which used both approaches and none of them is perfect. Both of them has pros and cons and in the end the issue isn’t what do you use as message IDs, but how do you setup translation workflow between your developers and translators.

The important take-away is: LinguiJS supports both and let you, as a developer, choose what works the best for you and for your team.

Translations with rich-text formatting

Let’s continue our journey with next message:

<p>

See all <Link to="/unread">unread messages</Link>{" or "}

<a onClick={markAsRead}>mark them</a> as read.

</p>

This one is a bit tricky, because the message includes React components. Ideally we would like to translate “See all unread messages or mark them as read” without any markup distractions. Unfortunately, we can’t predict easily how the translated message will look like, so we need to include minimal markup. Fortunately, <Trans> component does it for us automatically:

<p>

<Trans>

See all <Link to="/unread">unread messages</Link>{" or "}

<a onClick={markAsRead}>mark them</a> as read.

</Trans>

</p>

That’s correct. All we need to do again is wrap the message inside <Trans>. How the message will look like? React components including attributes are removed and the translators gets only the relevant information. The message ID will look like this:

See all <0>unread messages</0> or <1>mark them</1> as read.

I used to work on projects which included html attributes in a message and it always bugged me when we had to update translations after changing class or href. In this case translator gets everything they need and I’m able to use built-in elements and React components without any limitations.

As in previous example, if you prefer keys as message IDs, it works as well:

<p>

<Trans id="Inbox.actionsDescription">

See all <Link to="/unread">unread messages</Link>{" or "}

<a onClick={markAsRead}>mark them</a> as read.

</Trans>

</p>

Now the translator will get:

{

"Inbox.actionsDescription":

"See all <0>unread messages</0> or <1>mark them</1> as read."

}

Plurals

Next message is also tricky, but in a different way.

<p>

{

messagesCount === 1

? "There's {messagesCount} message in your inbox."

: "There're {messagesCount} messages in your inbox."

}

</p>

See that? Sometimes the message depends on a variable. It’s not just about including a variable inside the message, but using completely different form. This is common linguistic feature called pluralization and usually we avoid this problem using simplified message: {messageCount} message(s) in your inbox. Unfortunately this only works for languages with two plural forms (singular, plural). In Czech we have 3 plural forms and some languages have even more.

What now? We can’t have a different if condition for each language. That would require to refactor our app for each language. Instead, we’re going to use an internationalization message format which handles all required linguistic features.

Let me introduce you to ICU MessageFormat. This format is simple enough to be used by translators without programming knowledge, yet powerful enough to support all grammatical nuances of different languages.

Our message is expressed in ICU MessageFormat like this:

{messagesCount, plural,

one {There's # message in your inbox}

other {There're # messages in your inbox}

}

We see there’s a variable messageCount , followed by formatter plural and finally arguments for this formatter. one is used for singular and other for plural. In Czech this would be:

{messagesCount, plural,

one {Máte # zprávu v příchozí poště}

few {Máte # zprávy v příchozí poště}

other {Máte # zpráv v příchozí poště}

}

See the difference? The messages are almost identical except one word!

I guess you feel pretty confused at the moment, because this all seems to be pretty complex. But once again, LinguiJS simplifies things a lot.

This time we’re going to use <Plural> instead of <Trans> component:

<p>

<Plural

value={messagesCount}

one="There's # message in your inbox"

other="There're # messages in your inbox"

/>

</p>

We replaced the conditional with <Plural> component and added messages to one and other props. That’s all we need to do. LinguiJS automatically generates syntactically valid message in ICU MessageFormat.

We also don’t care about plurals for other languages. As a developers, we only need to provide plural forms for the language used in source (English in this example). Everything else is handled by translators and the library.

Plural under the hood

It’s not obvious how the <Plural> component works for translations with different plural forms (e.g. one, few, other in Czech), so let’s take a look how it works under the hood.

<Plural> component is parsed in Babel plugin and replaced with <Trans> component:

<p>

<Trans

id="{messagesCount, plural, one {There's # message in your inbox} other {There're # messages in your inbox}}"

values={{ messagesCount }}

/>

</p>

Formatting of plurals is handled by the core library using the language specific plural rules. When we get the translated message from translator, we don’t have to change our <Plural> component. It only serves as an easier syntax for ICU MessageFormat.

Also good to mention, that custom keys are supported for plurals as well:

<p>

<Plural

id="Inbox.messageCount"

value={messagesCount}

one="There's # message in your inbox"

other="There're # messages in your inbox"

/>

</p>

And the translator will get:

{

"Inbox.messageCount":

"{messagesCount, plural, one {There's # message in your inbox} other {There're # messages in your inbox}}"

}

Plurals are a bit difficult to understand, but very easy to use. I tried to provide deep explanation how they works, but most of these information are relevant only for translators. As developers, all we care about are the plural forms used by the default language.

Dates and Number formats

Finally we’re getting to the last message in our app. In previous section I talked about plurals, which are feature of a language. This time we’ll focus on a date and number formats, which depends on a country and culture.

In US it’s common to write dates in format MM/DD/YYYY , while in Czech we usually write YYYY/MM/DD . And again, as a developers we shouldn’t be required to know what date format we need to use. That’s a job for translators. Let’s just take the message, wrap it in <Trans> component as we’re used to and use <DateFormat> component for date formatting.

Original message:

<footer>

Last login on {lastLogin}.

</footer>

…will look like this:

<footer>

<Trans>

Last login on <DateTimeFormat value={lastLogin} />.

</Trans>

</footer>

Translators will work with message Last login on {lastLogin, date}. The lastLogin variable is formatted using date formatter, which uses Intl.DateTimeFormat under the hood (and therefore allows the same formatting options).

Number formatting is very similar, we just use <NumberFormat> instead.

Step 4— Create message catalog

Our work is mostly done. Once we have our app internationalized, we need to create a message catalog (which I referred to as a dictionary) and send it to the translators.

Here comes the CLI again. lingui extract command parses the source code, finds all messages for translation and extracts them to external file.

First, we need to define the languages we’re going to use with lingui add-locale command. Let’s start with English and Czech:

lingui add-locale en cs

We’ll get a confirmation and a git-like help what to do next:

Added locale en.

Added locale cs.



(use "lingui extract" to extract messages)

Running lingui extract creates a message catalogs inside locale directory:

lingui extract

The command outputs a statistics about total and missing translations:

Extracting messages from source files…

Collecting all messages…

Writing message catalogs…

Messages extracted!



Catalog statistics:

┌──────────┬─────────────┬─────────┐

│ Language │ Total count │ Missing │

├──────────┼─────────────┼─────────┤

│ cs │ 4 │ 4 │

│ en │ 4 │ 4 │

└──────────┴─────────────┴─────────┘



(use "lingui add-locale <locale>" to add more locales)

(use "lingui extract" to update catalogs with new messages)

(use "lingui compile" to compile catalogs for production)

Extracted empty catalog looks like this by default, but LinguiJS CLI also supports a po files:

{

"Message Inbox": "",

"See all <0>unread messages</0> or <1>mark them</1> as read.": "",

"{messagesCount, plural, one {There's {messagesCount} message in your inbox.} other {There're {messagesCount} messages in your inbox.}}": "",

"Last login on {lastLogin,date}.": "",

}

We’re using English in source code and because we don’t use keys as message IDs, we don’t have to translate English message catalog! Let’s fix it by adding lingui config to our package.json (skip this if you use keys as message IDs):

{

"lingui": {

"sourceLocale": "en",

}

}

Also, let me be your translator for Czech language. Here’s the catalog in Czech:

{

"Message Inbox":

"Přijaté zprávy", "See all <0>unread messages</0> or <1>mark them</1> as read.":

"Zobrazit všechny <0>nepřečtené zprávy</0> nebo je <1>označit</1> jako přečtené.", "{messagesCount, plural, one {There's {messagesCount} message in your inbox.} other {There're {messagesCount} messages in your inbox.}}":

"{messagesCount, plural, one {V příchozí poště je {messagesCount} zpráva.} few {V příchozí poště jsou {messagesCount} zprávy. } other {V příchozí poště je {messagesCount} zpráv.}}", "Last login on {lastLogin,date}.":

"Poslední přihlášení {lastLogin,date}",

}

Let’s run lingui extract again:

Extracting messages from source files…

Collecting all messages…

Writing message catalogs…

Messages extracted!



Catalog statistics:

┌────────────┬─────────────┬─────────┐

│ Language │ Total count │ Missing │

├────────────┼─────────────┼─────────┤

│ cs │ 4 │ 0 │

│ en (source)│ 4 │ - │

└────────────┴─────────────┴─────────┘



(use "lingui add-locale <locale>" to add more locales)

(use "lingui extract" to update catalogs with new messages)

(use "lingui compile" to compile catalogs for production)

That’s it! We now have all required translations and we can proceed to final step. As you can see, we can run lingui extract anytime and it’ll merge existing translations with any new messages. It’ll also mark obsolete messages when we delete a message from source code.

Step 5— Load translations

Here comes the part which is a bit different from other i18n libraries. Before we load translated message catalogs, we need to compile them. Compilation turns messages in ICU MessageFormat into simple JavaScript functions which accepts values for interpolation.

Compilation makes library very small and fast because we don’t need to include message parser in production bundle.

We can either compile message catalogs manually using lingui compile :

Compiling message catalogs…

Done!

This command creates messages.js next to original messages.json . It’s good to mention, that messages.json is a file for translators, while messages.js is loaded into our app.

If you’re using webpack, you can use @lingui/loader which compiles messages on the fly.

Now we need to load catalog into our app. Let’s go to the beginning of our tutorial:

// index.js

import React from 'react'

import { render } from 'react-dom'

import Inbox from './Inbox.js'

import catalogEn from './locale/en/messages.js'

import catalogCs from './locale/cs/messages.js'



import { I18nProvider } from '@lingui/react' const catalogs = { en: catalogEn, cs: catalogCs } const App = () => (

<I18nProvider language="en" catalogs={catalogs}>

<Inbox />

</I18nProvider>

)



render(<App />, document.getElementById('app'))

To simplify this long tutorial, we’re loading all messages at once. catalogs prop accept a dictionary where keys are available languages and values are compiled catalogs.

If you’re interested in more sophisticated use case, take a look at this guide about dynamic loading of message catalogs using webpack.

Step 6 — What’s next

There’s some work to be done like switching languages (depends on data layer you’re using with React), but the basics of i18n are covered.

There’re many common i18n patterns which are described in React — common patterns tutorial like translation of text attributes or using LinguiJS outside React context (e.g. in redux-saga).

Checkout the comprehensive documentation for other tutorials, guides and references.

The future

Next version of LinguiJS is coming in near future. Babel plugins are gonna be replaced with babel macros, which are supported in many frameworks. For example, I already tested development version with upcoming create-react-app 2.0 and it works perfectly.

Don’t get me wrong, you can use LinguiJS with CRA right now, but without plugins and seamless syntax, it’s just another i18n lib (with smaller footprint though).

Upcoming version will be also a bit smaller. Right now the core lib is 2kb gzipped and React components add another 3kb gzipped. In total, that’s twice less that emotion for full-featured i18n lib.

I’m also working on out-of-the-box code splitting, but that’s gonna need a bit more research.

If you’re interested, give it a try. Join us on gitter or share your experience on twitter. I’m very curious about your opinion or suggestions.