6. Internationalization
6.1. Example 21: A Bilingual Application
[React] does not have a translation system. The most commonly used library is [i18next], which is linked to [React] via [react-i18next].
6.1.1. Dictionaries
| {
"APP": {
"TITLE": "Exemple 21 - une application en deux langues",
"LANGUAGE": "Langue"
},
"HOME": {
"HELLO": "Bonjour {{name}} !",
"TODAY": "Nous sommes le {{date, datetime(dateStyle: full)}}.",
"PRICE": "Une consultation coûte {{price, currency(EUR)}}.",
"APPOINTMENTS_zero": "aucun rendez-vous",
"APPOINTMENTS_one": "un rendez-vous",
"APPOINTMENTS_other": "{{count}} rendez-vous",
"ADD": "Ajouter un rendez-vous",
"REMOVE": "En retirer un"
},
"ERRORS": {
"LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
"TOO_MANY_ATTEMPTS": "Trop de tentatives infructueuses. Réessayez dans {{minutes}} minute(s)."
},
"SERVER": {
"TITLE": "Traduire une réponse du serveur",
"EXPLANATION": "Le serveur RdvMedecins ne renvoie que des clés. C'est le client qui les traduit :"
}
}
|
| {
"APP": {
"TITLE": "Example 21 - a two-language application",
"LANGUAGE": "Language"
},
"HOME": {
"HELLO": "Hello {{name}}!",
"TODAY": "Today is {{date, datetime(dateStyle: full)}}.",
"PRICE": "A consultation costs {{price, currency(EUR)}}.",
"APPOINTMENTS_zero": "no appointment",
"APPOINTMENTS_one": "one appointment",
"APPOINTMENTS_other": "{{count}} appointments",
"ADD": "Add an appointment",
"REMOVE": "Remove one"
},
"ERRORS": {
"LOGIN_TAKEN": "This login is already used by another account.",
"TOO_MANY_ATTEMPTS": "Too many failed attempts. Try again in {{minutes}} minute(s)."
},
"SERVER": {
"TITLE": "Translating a server response",
"EXPLANATION": "The RdvMedecins server only sends keys. The client translates them:"
}
}
|
Each text has a key consisting of nested names: [HOME.HELLO]. These are the same dictionaries as in previous lessons, with two specific features of [i18next]:
- parameters are written as [{{name}}]; a parameter can be formatted according to the language: [{{date, datetime(dateStyle: full)}}] (a date, with the options from [Intl.DateTimeFormat]), [{{price, currency(EUR)}}] (an amount in euros);
- plurals: [i18next] automatically selects the form based on the parameter [count] and the language rules: [APPOINTMENTS_one] or [APPOINTMENTS_other], and [APPOINTMENTS_zero] for zero, if it exists. Polish, Arabic, and other languages have different forms ([_few], [_many]), which [i18next] recognizes.
6.1.2. The configuration
| // =========================================================================
// 21-i18n/src/i18n/i18n.ts - the configuration of i18next
// -------------------------------------------------------------------------
// i18next is the most used translation library in JavaScript;
// react-i18next links it to React. We provide it with:
// - one DICTIONARY per language (fr.json, en.json), where each text has a
// KEY ("HOME.HELLO"); the parameters are written {{name}};
// {{date, datetime(...)}} and {{price, currency(EUR)}} format
// a date or an amount according to the language (with the Intl API);
// - the initial language (the one stored in localStorage, otherwise that
// of the browser, otherwise English) and the fallback language.
// In a component, t('HOME.HELLO', { name: 'Marie' }) gives the text
// of the CURRENT language; changing the language calls again the components that
// use t, and the whole page is translated again.
//
// It is, on the client side, the same principle as the NestJS course (dictionaries
// i18n/*.json + helper {{t}}) - but the translation is now done in
// the browser, and changing the language no longer asks anything of the server.
// =========================================================================
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './fr.json';
import en from './en.json';
export type Language = 'fr' | 'en';
export const LANGUAGES: Language[] = ['fr', 'en'];
const LANGUAGE_KEY = 'example21.language';
function initialLanguage(): Language {
const stored = localStorage.getItem(LANGUAGE_KEY);
if (stored === 'fr' || stored === 'en') {
return stored;
}
return navigator.language.startsWith('fr') ? 'fr' : 'en';
}
// on each change of language: it is stored, and the lang attribute
// of the page is updated (useful to screen readers, to spell checkers)
i18n.on('languageChanged', (language) => {
localStorage.setItem(LANGUAGE_KEY, language);
document.documentElement.lang = language;
});
// use(initReactI18next): i18next notifies React of the changes of language
i18n.use(initReactI18next).init({
// the dictionaries are part of the code of the application: no request
resources: { fr: { translation: fr }, en: { translation: en } },
lng: initialLanguage(),
fallbackLng: 'en', // a key missing in French: the English one is used
// React already escapes everything it displays: i18next does not have to
interpolation: { escapeValue: false },
});
export default i18n;
|
- lines 23–24: the dictionaries are part of the application code: they are JSON modules, imported as objects ([resolveJsonModule], see [tsconfig.json]);
- lines 30–36: the source language: the one stored in [localStorage], otherwise French if the browser’s language is French, otherwise English;
- lines 40–43: each time the language changes, it is stored and the page’s [lang] attribute is updated (useful for screen readers and spell-checkers);
- lines 46–53: configuration: the dictionaries ([translation] is the name of the default key space), the source language, the fallback language (if a key is missing in French, English is used), and [escapeValue: false]: [React] already escapes everything it displays.
| // =========================================================================
// 21-i18n/src/main.tsx - importing i18n/i18n.ts is enough to configure i18next,
// before the first display
// =========================================================================
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import './i18n/i18n';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
|
Importing [i18n/i18n.ts] is sufficient to configure [i18next] before the first display.
6.1.3. The translated page
| // =========================================================================
// 21-i18n/src/App.tsx - a translated page
// -------------------------------------------------------------------------
// - const { t, i18n } = useTranslation(): the translation function, and
// the i18next object (i18n.language: the current language);
// - t('KEY') a text;
// - t('KEY', { name: ... }) a text with parameters;
// - t('KEY', { count: n }) a text in the plural: i18next chooses
// the key KEY_zero, KEY_one or KEY_other;
// - i18n.changeLanguage('fr') changes the language: the whole page is
// translated again.
// =========================================================================
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LANGUAGES } from './i18n/i18n';
// what the RdvMedecins server would return (cf. chapter on the black box)
const SERVER_RESPONSES = [
{ statusCode: 409, key: 'ERRORS.LOGIN_TAKEN', params: {} },
{ statusCode: 429, key: 'ERRORS.TOO_MANY_ATTEMPTS', params: { minutes: '15' } },
];
export default function App() {
const { t, i18n } = useTranslation();
const [count, setCount] = useState(2);
const [today] = useState(() => new Date());
// the title of the tab follows the language
useEffect(() => {
document.title = t('APP.TITLE');
}, [t]);
return (
<div className="container py-4" style={{ maxWidth: 720 }}>
<div className="d-flex justify-content-between align-items-center mb-3">
<h1 className="h3 mb-0">{t('APP.TITLE')}</h1>
<div className="btn-group btn-group-sm" role="group" aria-label={t('APP.LANGUAGE')}>
{LANGUAGES.map((l) => (
<button key={l} className={`btn ${i18n.language === l ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => i18n.changeLanguage(l)}>
{l.toUpperCase()}
</button>
))}
</div>
</div>
<div className="card p-3 mb-3">
<p>{t('HOME.HELLO', { name: 'Marie' })}</p>
{/* the date and the amount are formatted by the dictionary, in the current language */}
<p>{t('HOME.TODAY', { date: today })}</p>
<p>{t('HOME.PRICE', { price: 1234.5 })}</p>
{/* count: chooses the plural form, and replaces {{count}} */}
<p className="fw-bold">{t('HOME.APPOINTMENTS', { count })}</p>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-success" onClick={() => setCount(count + 1)}>{t('HOME.ADD')}</button>
<button className="btn btn-sm btn-outline-secondary" disabled={count === 0} onClick={() => setCount(count - 1)}>
{t('HOME.REMOVE')}
</button>
</div>
</div>
<div className="card p-3">
<h2 className="h6">{t('SERVER.TITLE')}</h2>
<p>{t('SERVER.EXPLANATION')}</p>
<ul className="mb-0">
{SERVER_RESPONSES.map((r) => (
// the key of the server IS a key of the dictionary; params provides its parameters
<li key={r.key}>
<code>{r.statusCode} {r.key}</code>
<br />→ {t(r.key, r.params)}
</li>
))}
</ul>
</div>
</div>
);
}
|
- Line 25: the hook for [react-i18next]: [t], the translation function, and [i18n], the [i18next] object ([i18n.language] is the current language). The component is called when the language changes;
- lines 30–32: the tab title follows the language: the function [t] changes with the language;
- line 41: change language: the entire page is re-translated without making any requests to the server;
- line 49: text with parameters;
- lines 51–52: a date and an amount, formatted by the dictionary in the current language;
- line 54: a plural form;
- line 71: the key sent by the server [RdvMedecins] IS a key from the dictionary (see next chapter); [params] provides its parameters.

In English, after removing two appointments:

On the client side, this works on the same principle as the [NestJS] course ([i18n/*.json] dictionaries + [{{t}}] helper); however, translation now takes place in the browser, and changing languages no longer requires any interaction with the server.