Introduce multi-language support in a TypeScript/React app with lingui.js

Camille Drapier
Wantedly Engineering
6 min readAug 19, 2019

One of the focus of our engineering team recently was to extract features from our main Rails monolith to different smaller micro-services. One of these tasks was to take care of the UI that is used to create a “Job Post”. This new Single Page App micro-service was developed using React and TypeScript. Since we needed it to support multiple languages (at least Japanese and English for now), I would like to share here the process of translating that UI with the lingui-js library (2.8.3).

Choosing the right framework

Before going into the details I would like to go through some of the motivations behind choosing lingui-js over react-intl, i18next-react, polyglot, etc.

To put it very simply, react-intl looked too big for the needs we had. i18next-react was appropriate, but after prototyping it we found out that it was quite difficult to use the numerous i18next-react’s plug-ins with React/TypeScript. For example, i18next has a lot of plug-ins and the react pendant is one of them, but not all plug-ins seem cross-compatible; especially the ones that can extract/validate the translations. On the other hand, polyglot did not provide enough functionality (side-loading, extractions, supports only one format for the translation files). So in the end, we were pretty satisfied with lingui-js, due to the fact that it worked out of the box, and that it has some nice documentation.

Installation

As we are using the React Create App, the available installation guide was straightforward and self-explanatory.

yarn add -D @lingui/cli @lingui/macro @babel/core babel-core@bridge
yarn add @lingui/react

Add the .linguirc file to the root directory with:

{
"localeDir": "src/assets/locales/",
"srcPathDirs": ["src/"],
"format": "po",
"sourceLocale": "ja"
}

Append scripts to package.json (Note that we chose to prefix our commands by i18n:):

{
"scripts": {
...
"i18n:add-locale": "lingui add-locale",
"i18n:extract": "lingui extract",
"i18n:compile": "lingui compile"
...
}
}

Add our initial locales:

yarn i18n:add-locale ja en

Initialization

Before going into the details, I would like to mention some of the requirements of our app that lead us to make certain choices hereafter:

  • there is no need to change the language on the fly
  • the page needs to wait for the session data to be loaded before displaying anything

Because of our specific needs and architecture, we decided to insert a component just under the one loading the session data (it then provides the locale through our useLocale() hook) and just above the one rendering the actual content.

Our app already had the following behaviour: showing a loader until there is something to display. Thus, we had to load the correct translation files and render an empty div until that was done in order for the loader to display.

import React from "react";
import { I18nProvider, I18n } from "@lingui/react";
import { useLocale } from "../hooks/useLocale";
export const I18nContextProvider: React.FC = props => {
const [catalogs, setCatalogs] = React.useState<{ [key: string]: any }>({});
const locale = useLocale();
React.useEffect(() => {
const loadCatalog = async (locale: string) => {
const catalog = await import(
/* webpackMode: "lazy", webpackChunkName: "i18n-[index]" */
`@lingui/loader!../../assets/locales/${locale}/messages.po`
);
setCatalogs({
...catalogs,
[locale]: catalog,
});
};
loadCatalog(locale);
}, [locale]);
if (!catalogs[locale]) return <div />;return (
<I18nProvider language={locale} catalogs={catalogs}>
{props.children}
</I18nProvider>
);
};

We then just inserted our provider between our SessionContextProvider and our AppRouter:

...
<SessionContextProvider>
<I18nContextProvider>
...
<AppRouter />

Extraction

Once the base infrastructure was set up, we needed to extract all the existing texts (in Japanese in our case) and that is where the yarn i18n:extract command comes in useful. To extract most of our texts we followed this guideline:

  1. Import the Trans definition: import { Trans } from "@lingui/macro";
  2. Surround the text we want to extract by <Trans> elements
  3. Give a specific id to that <Trans id="example"> element
  4. Run the yarn i18n:extract command
  5. Remove the text part inside the <Trans> node
  6. (Optional) If nothing is left inside the remaining <Trans id="example"></Trans>change it to <Trans id="example" />
  7. (Optional) If the remaining <Trans id="example" is rendered in a single node (for example <Header><Trans id="example"><br /></Trans></Header>) then use the render helper method to simplify the code. For example: <Header><Trans id="example"><br /></Trans></Header>would become: <Trans id="example" render={<Header />}><br /></Trans>
  8. (Optional) Run the yarn i18n:extract command again to update the corresponding lines if any change happened.

The reason why we wanted to use specific ids, and removed the original text from the view/components, was to limit confusion and maintenance costs for when the labels need to change in the future. Single Source Of Truth!

Marking

For Strings that are not directly present in a rendering part of the react app (validation/errors messages, placeholders, etc) we cannot use the normal <Trans> component. In that case, we would have to to use the <I18n> component that gives access to the i18n instance. We can then use this instance in the code. For example:

import { I18n } from "@lingui/core";
...
<>
<I18n>{({ i18n }) => <TextArea placeholder={i18n("example")}></I18n>
</>

However, sometimes the key cannot be determined without having some calculation beforehand so instead of "example" we will need to call a function. This works fine at runtime but the extraction tool cannot recognize the key/value to extract any more, so to mitigate that problem we can use the i18nMark helper that will allow the extractor to find the adequate ids in the code:

<Trans 
id={isMeetup ? i18nMark("projectEdit/formDetailMeetupTitle") : i18nMark("projectEdit/formDetailTitle")}
/>

Hooks

Because the use of the “<I18n> render prop component” was a bit verbose, we decided to create a small hook that we can reuse in our code:

import React, { useContext } from "react";
import { I18n } from "@lingui/core";
const I18nFuncContext = React.createContext<I18n | null>(null);
export const I18nFuncContextProvider = I18nFuncContext.Provider;
export function useI18n(): I18n {
const i18n = useContext(I18nFuncContext);
if (!i18n) {
throw new Error("No context found");
}
return i18n;
}

Then we can just initialize it in our custom provider:

return (
<I18nProvider language={locale} catalogs={catalogs}>
<I18n>
{({ i18n }) => <I18nFuncContextProvider value={i18n}>{props.children}</I18nFuncContextProvider>}
</I18n>
</I18nProvider>
);

And use it in our code:

import { useI18n } from "./useI18n";
...
const i18n = useI18n();...
<TextArea placeholder={i18n._("example")} />
...

Note that hooks should be available natively from version 3 of lingui-js.

Changing the title

Changing the title of the page might be a bit tricky. So after a bit of research about it, we created a special component for that purpose, and inserted that as the first child of our <I18nContextProvider>:

...
<SessionContextProvider>
<I18nContextProvider>
<I18nTitle />
...
<AppRouter />

The title component uses a simple effect to change the document title when the component is loaded:

import React, { useEffect } from "react";
import { useI18n } from "../hooks/useI18n";
export const I18nTitle: React.FC = () => {
const i18n = useI18n();
useEffect(() => {
document.title = i18n._("common/documentTitle");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
};

Final Words

We are quite happy with the result of using lingui-js to translate our app. Some small parts could be improved, such as:

  • the po format giving us some conflicts now and then when we work parallelly on the same component with git
  • the custom hook
  • the lack of options to automatically erase the source text when extracting

Overall, I can say we probably spent more time in assessing and choosing the right library than using this one to translate our own app. 😅 🎉

Here’s a glimpse at the output of our yarn i18n:extract and at the final result of our UI:

┌─────────────┬─────────────┬─────────┐
│ Language │ Total count │ Missing │
├─────────────┼─────────────┼─────────┤
│ en │ 187 │ 1 │
│ ja (source) │ 187 │ - │
└─────────────┴─────────────┴─────────┘

(The missing one being an URL that only exists in Japanese as of now)

Japanese and English version of the front page

Thank you for Reading! 📖

Also thanks to Yuki Iwanaga (Creasty) for helping cleaning up things with our custom hook.

--

--