When you want to support a large variety of users, internationalization (i18n) is an essential step to make your app more accessible. Like many concerns in React.js, there’s no built-in solution for i18n. Let’s explore which package you should use, what’s an ideal setup for i18n & how you can collaborate with designers on localizing your app.
Internationalization is the first step of adapting a product to multiple locales & regions.
There are many other localization aspects to consider (dates, numbers, content direction, adaptive UI), but here are the questions you should ask yourself when choosing an i18n package:
In the React ecosystem, there are two libraries to highlight:
react-i18next
: Based on the i18next framework available for other UI libraries & languagesreact-intl
: Part of a set of libraries called Format.js, based on Javascript Intl built-ins & industry standardsIn this guide, we’ll focus on react-i18next
for the following reasons:
react-i18next
The code used for demonstration in this post is available in this GitHub repository.
In your React.js project, run the following command to install i18next
& react-i18next
:
# Using Yarn
yarn add i18next react-i18next
# Using NPM
npm install i18next react-i18next
Let’s say you want to translate your app in English & French, we’re gonna use the following file structure:
└── src
├── App.tsx
├── i18n
│ ├── config.ts
│ └── locales
│ ├── en-GB.json
│ └── fr-FR.json
└── index.tsx
The locales folder contains a JSON file for each language you support. It can be easily synced with a translation management tool like Recontent.app.
The src/i18n/config.ts
file contains the setup for react-i18next
:
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enGB from "./locales/en-GB.json";
import frFR from "./locales/fr-FR.json";
const defaultLanguage = "en-GB";
export const defaultNamespace = "default";
export const resources = {
"en-GB": {
[defaultNamespace]: enGB,
},
"fr-FR": {
[defaultNamespace]: frFR,
},
};
i18n.use(initReactI18next).init({
defaultNS: defaultNamespace,
ns: [defaultNamespace],
resources,
lng: defaultLanguage,
fallbackLng: defaultLanguage,
interpolation: {
escapeValue: false,
},
});
To effectively initialize react-i18next
, import that config file in your app entry point:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./i18n/config";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
To make translations available to use in your code, populate JSON files for each language with keys. Keys can be nested in objects or have a flat structure. We’ll see in the following section how to reference them in your code.
{
"dashboard": {
"title": "Welcome to your dashboard!"
}
}
react-i18next
Now that your app is setup to use i18n, let’s see how to actually reference translations in your code, change the language or have proper autocompletion & typesafety with Typescript.
react-i18next
exposes hooks & components in order to use your translations.
The useTranslation
hook is the main one you can use to replace hardcoded text with dynamic references to your translations based on current language.
import { useTranslation } from "react-i18next";
export const Dashboard = () => {
const { t } = useTranslation();
/**
* Dot notation indicates `react-i18next` to search
* for nested translations in JSON objects
*/
return (
<div>
<h1>{t("dashboard.title")}</h1>
</div>
);
};
Plurals are also supported by using a suffix in your key:
import { useTranslation } from "react-i18next";
export const Dashboard = () => {
const { t } = useTranslation();
const project_count = 2;
/**
* {
* "success_notification": "Project created, you can access it from your dashboard."
* "success_notification_plural": "Projects created, find all of them on your dashboard."
* }
*/
return (
<div>
<h1>{t("success_notification", { count: project_count })}</h1>
</div>
);
};
You can even interpolate your own components within a translation for complex use cases (eg. Status badge, icon):
import { FC } from "react";
import { Trans, useTranslation } from "react-i18next";
const StatusBadge = () => {
const { t } = useTranslation();
return (
<div style={{ backgroundColor: "purple" }}>
{t("utils.status_accepted")}
</div>
);
};
export const Dashboard = () => {
const { t } = useTranslation();
/**
* {
* "utils": {
* "status_badge": "Your invoice has been <StatusBadge />"
* "status_accepted": "Accepted"
* }
* }
*/
return (
<div>
<Header />
<Trans
i18nKey="utils.status_badge"
t={t}
components={{ StatusBadge: <StatusBadge /> }}
/>
</div>
);
};
The i18next
ecosystem comes with many handy plugins for concerns like detecting browser language or loading translations from backends.
If you wish to detect user languages, you can install i18next-browser-languagedetector
& use it in your config.ts
file:
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
// highlight-start
import LanguageDetector from "i18next-browser-languagedetector";
// highlight-end
i18n
.use(initReactI18next)
// highlight-start
.use(LanguageDetector)
// highlight-end
.init({
defaultNS: defaultNamespace,
ns: [defaultNamespace],
resources,
lng: defaultLanguage,
fallbackLng: defaultLanguage,
interpolation: {
escapeValue: false,
},
});
To change the current language, the i18n object has a changeLanguage
method that you can call with any registered locale:
import i18next from "i18next";
export const changeLanguage = async (language) => {
await i18next.changeLanguage(language);
};
As any component in your React codebase, components using i18n keys can be tested.
You can either choose to initialize react-i18next
in your tests & make sure translated texts are rendered correctly or rely on keys.
If you choose to rely on keys and not initialize react-i18next
in your tests, make sure to mock it:
import "@testing-library/jest-dom";
jest.mock("react-i18next", () => ({
useTranslation: () => {
return {
t: (key: string) => key,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
};
},
}));
A test for your component using react-testing-library
can look like this:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HelpButton } from "./HelpButton";
describe("HelpButton", () => {
it("displays description after clicking", async () => {
render(<HelpButton />);
expect(screen.getByText("help_button.cta")).toBeInTheDocument();
expect(
screen.queryByText("help_button.description")
).not.toBeInTheDocument();
await userEvent.click(screen.getByText("help_button.cta"));
expect(screen.getByText("help_button.description")).toBeInTheDocument();
});
});
If you choose to test rendered translations, make sure to use i18n.t()
in your tests instead of directly referencing translations. This way, if a key used in your tests is removed, it will fail & prevent your app from being deployed with a missing key. Moreover, your tests will not fail every time your translations are updated.
Here are some tips & tricks to improve your translation & development workflow when working on internationalization.
As you add features & screens to your app, your translations tend to grow exponentially. This leads to unintended behaviour like:
Thanksfully, with react-i18next
, you can split your translations in multiple namespaces. Namespaces & associated translations can be loaded at app intialization or later when navigating in parts of your app.
import i18next from "i18next";
import enGB from "./locales/en-GB.json";
import frFR from "./locales/fr-FR.json";
i18next.addResourceBundle("fr-FR", "admin", frFR);
i18next.addResourceBundle("en-GB", "admin", enGB);
If you already use Typescript in your React.js codebase, you might want to extend the typesafety you have in your code to i18n keys. Indeed, translations are referenced using strings in your code. It’s easy to make a typo & release a broken UI to your users.
Moreover, with proper typing, your IDE will be able to autocomplete your keys for better developer experience.
Create a i18next.d.ts
in the root folder and add the following content:
import { resources, defaultNamespace } from "./src/i18n/config";
declare module "i18next" {
interface CustomTypeOptions {
returnNull: false;
defaultNS: typeof defaultNamespace;
resources: (typeof resources)["en-GB"];
}
}
The CustomTypeOptions
is augmented to add types to i18next
default resources. Typescript should now only allow existing keys within the src/i18n/locales/en-GB.json
file.
With this setup, your codebase is more maintainable: translations are stored is identified places and updates to them can be made without touching the source code.
If you want to deploy your app to new regions, supporting a new language is possible by adding a new locale.json
file.
However, within the product cycle, there are still many issues:
With Recontent.app, you get a friendly interface for designers, developers & product managers to collaborate.