Skip to content

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 />&rarr; {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.

Image

In English, after removing two appointments:

Image

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.