Cours JavaScript et React · Module 10: Écosystème et avenir de React

React-i18next - Internationalisation des applications React

16 min de lecture
Dans cette leçon11

Un équipage venu de toute la galaxie monte à bord : le capitaine lit les messages en polonais, la navigatrice en anglais et l'ingénieur en espagnol. Si les textes sont écrits en dur dans le JSX, chaque nouvelle langue oblige à réécrire les composants. La solution, c'est l'internationalisation (i18n) : le composant ne connaît que des clés, et les traductions se trouvent dans des fichiers séparés. React-i18next relie React à la bibliothèque i18next, qui formate aussi les dates, les nombres et les devises.

Installation et configuration

Tu installes le cœur i18next, l'intégration react-i18next et deux plugins optionnels : un détecteur de langue et un backend HTTP :

1npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend

La configuration est une chaîne : chaque use() branche un plugin, init() fixe les options, et à la fin tu exportes l'instance :

1// i18n.js
2import i18n from 'i18next';
3import { initReactI18next } from 'react-i18next';
4import Backend from 'i18next-http-backend';
5import LanguageDetector from 'i18next-browser-languagedetector';
6
7i18n
8  .use(Backend) // Chargement des traductions depuis le serveur
9  .use(LanguageDetector) // Détection automatique de la langue
10  .use(initReactI18next) // Intégration avec React
11  .init({
12    // Configuration du fallback
13    fallbackLng: 'en',
14
15    // Débogage en développement
16    debug: process.env.NODE_ENV === 'development',
17
18    // Interpolation
19    interpolation: {
20      escapeValue: false // React protège déjà contre les XSS
21    },
22
23    // Configuration du backend
24    backend: {
25      loadPath: '/locales/{{lng}}/{{ns}}.json'
26    },
27
28    // Configuration du détecteur de langue
29    detection: {
30      order: ['localStorage', 'navigator', 'htmlTag'],
31      caches: ['localStorage']
32    }
33  });
34
35export default i18n;

fallbackLng: 'en' est la langue de secours, et escapeValue: false désactive le double échappement, car React protège déjà contre les attaques XSS. Dans un projet plus simple, au lieu d'un backend tu fournis directement les traductions : init({ resources, lng }).

Tu importes la configuration une seule fois, dans le fichier de démarrage, avant de rendre l'application :

1// index.js
2import React from 'react';
3import ReactDOM from 'react-dom/client';
4import './i18n'; // Import de la configuration
5import App from './App';
6
7const root = ReactDOM.createRoot(document.getElementById('root'));
8root.render(<App />);

L'import ne fait qu'initialiser i18next, donc App ne reçoit rien par les props.

Le hook useTranslation

Dans un composant, tu utilises useTranslation. Le hook renvoie une fonction t() qui transforme une clé en texte, et un objet i18n avec la méthode changeLanguage :

1// App.jsx
2import { useTranslation } from 'react-i18next';
3
4function App() {
5  const { t, i18n } = useTranslation();
6
7  const changeLanguage = (lang) => {
8    i18n.changeLanguage(lang);
9  };
10
11  return (
12    <div>
13      <h1>{t('welcome.title')}</h1>
14      <p>{t('welcome.description')}</p>
15
16      <div>
17        <button onClick={() => changeLanguage('en')}>English</button>
18        <button onClick={() => changeLanguage('pl')}>Polski</button>
19        <button onClick={() => changeLanguage('es')}>Español</button>
20      </div>
21
22      <p>{t('current_language')}: {i18n.language}</p>
23    </div>
24  );
25}
26
27export default App;

La clé welcome.title désigne un champ imbriqué dans le JSON. Après changeLanguage('pl'), chaque composant qui utilise ce hook se rend à nouveau avec les nouveaux textes.

Les fichiers de traduction ont des clés identiques et ne diffèrent que par leurs valeurs :

1// public/locales/en/translation.json
2{
3  "welcome": {
4    "title": "Welcome to our application",
5    "description": "This is a multilingual React application"
6  },
7  "current_language": "Current language",
8  "navigation": {
9    "home": "Home",
10    "about": "About",
11    "contact": "Contact"
12  }
13}
14
15// public/locales/pl/translation.json
16{
17  "welcome": {
18    "title": "Witaj w naszej aplikacji",
19    "description": "To jest wielojęzyczna aplikacja React"
20  },
21  "current_language": "Aktualny język",
22  "navigation": {
23    "home": "Strona główna",
24    "about": "O nas",
25    "contact": "Kontakt"
26  }
27}

Le backend les trouvera au chemin /locales/{{lng}}/{{ns}}.json, où lng est la langue et ns l'espace de noms.

Interpolation et pluralisation

Tu passes les données, comme un prénom ou un nombre de messages, en deuxième argument de la fonction t() :

1function UserProfile({ user, messageCount }) {
2  const { t } = useTranslation();
3
4  return (
5    <div>
6      {/* Interpolation */}
7      <h1>{t('user.greeting', { name: user.name })}</h1>
8
9      {/* Pluralisation */}
10      <p>{t('messages.count', { count: messageCount })}</p>
11
12      {/* Formatage des dates */}
13      <p>{t('user.joined', {
14        date: new Date(user.joinedAt),
15        formatParams: {
16          date: { year: 'numeric', month: 'long', day: 'numeric' }
17        }
18      })}</p>
19    </div>
20  );
21}

{{name}} dans la traduction sera remplacé par la valeur de user.name. Pour le pluriel, la variable doit s'appeler exactement count. La date est formatée par {{date, datetime}} dans la traduction, et formatParams lui transmet les options d'Intl.DateTimeFormat.

Les formes du pluriel se distinguent par des suffixes de clés :

1// Fichiers de traduction avec pluralisation
2{
3  "user": {
4    "greeting": "Hello, {{name}}!",
5    "joined": "Member since {{date, datetime}}"
6  },
7  "messages": {
8    "count_zero": "No messages",
9    "count_one": "1 message",
10    "count_other": "{{count}} messages"
11  }
12}

Depuis la version 21, i18next choisit le suffixe grâce à Intl.PluralRules : l'anglais a les formes _one et _other, et _zero est une variante supplémentaire pour zéro. Le polonais a besoin de _one (1 wiadomość), _few (2 wiadomości), _many (5 wiadomości) et _other (fractions). Le composant ne change pas, seuls les fichiers changent.

Espaces de noms

Les namespaces découpent un énorme fichier de traductions en modules lisibles, par exemple navigation, forms et errors :

1// Configuration avec des namespaces
2i18n.init({
3  // Espace de noms par défaut et liste de tous les espaces
4  defaultNS: 'common',
5  ns: ['common', 'navigation', 'forms', 'errors']
6});
7
8// Utilisation de différents namespaces
9function Navigation() {
10  const { t } = useTranslation('navigation');
11
12  return (
13    <nav>
14      <a href="/">{t('home')}</a>
15      <a href="/about">{t('about')}</a>
16      <a href="/contact">{t('contact')}</a>
17    </nav>
18  );
19}
20
21function ContactForm() {
22  const { t } = useTranslation(['forms', 'errors']);
23
24  return (
25    <form>
26      <label>{t('forms:name.label')}</label>
27      <input
28        required
29      />
30
31      <label>{t('forms:email.label')}</label>
32      <input
33        type="email"
34        required
35      />
36
37      <button type="submit">
38        {t('forms:submit')}
39      </button>
40    </form>
41  );
42}

useTranslation('navigation') choisit l'espace de noms du composant, et la notation forms:name.label va chercher une clé dans un espace précis.

Contexte de langue

Un sélecteur de langue a généralement besoin d'enregistrer le choix, d'une liste de langues et du sens du texte. LanguageProvider rassemble tout cela :

1// LanguageContext.jsx
2import { createContext, useContext, useState } from 'react';
3import { useTranslation } from 'react-i18next';
4
5const LanguageContext = createContext();
6
7export function LanguageProvider({ children }) {
8  const { i18n } = useTranslation();
9  const [language, setLanguage] = useState(i18n.language);
10
11  const changeLanguage = async (newLang) => {
12    await i18n.changeLanguage(newLang);
13    setLanguage(newLang);
14
15    // Enregistrer le choix dans localStorage
16    localStorage.setItem('preferredLanguage', newLang);
17
18    // Changer le sens du texte pour les langues RTL
19    if (['ar', 'he', 'fa'].includes(newLang)) {
20      document.dir = 'rtl';
21    } else {
22      document.dir = 'ltr';
23    }
24  };
25
26  const availableLanguages = [
27    { code: 'en', name: 'English' },
28    { code: 'pl', name: 'Polski' },
29    { code: 'es', name: 'Español' },
30    { code: 'fr', name: 'Français' },
31    { code: 'de', name: 'Deutsch' }
32  ];
33
34  return (
35    <LanguageContext.Provider value={{
36      language,
37      changeLanguage,
38      availableLanguages,
39      isRTL: ['ar', 'he', 'fa'].includes(language)
40    }}>
41      {children}
42    </LanguageContext.Provider>
43  );
44}
45
46export const useLanguage = () => {
47  const context = useContext(LanguageContext);
48  if (!context) {
49    throw new Error('useLanguage must be used within LanguageProvider');
50  }
51  return context;
52};
53
54// LanguageSelector.jsx
55function LanguageSelector() {
56  const { language, changeLanguage, availableLanguages } = useLanguage();
57  const { t } = useTranslation();
58
59  return (
60    <div className="language-selector">
61      <select
62        value={language}
63        onChange={(e) => changeLanguage(e.target.value)}
64        aria-label={t('common:select_language')}
65      >
66        {availableLanguages.map(lang => (
67          <option key={lang.code} value={lang.code}>
68            {lang.name}
69          </option>
70        ))}
71      </select>
72    </div>
73  );
74}

Pour l'arabe, l'hébreu et le persan, le provider définit document.dir = 'rtl'. LanguageSelector est un simple select piloté par le contexte.

Dates, nombres et devises

Chaque pays écrit les dates différemment, donc au lieu de les formater à la main, utilise Intl.DateTimeFormat avec la langue de i18n.language :

1// DateFormatter.jsx
2import { useTranslation } from 'react-i18next';
3
4function DateFormatter({ date, format = 'short' }) {
5  const { i18n } = useTranslation();
6
7  const formatDate = (date, format) => {
8    const formatOptions = {
9      short: {
10        year: 'numeric',
11        month: 'short',
12        day: 'numeric'
13      },
14      long: {
15        year: 'numeric',
16        month: 'long',
17        day: 'numeric',
18        weekday: 'long'
19      },
20      time: {
21        hour: '2-digit',
22        minute: '2-digit'
23      },
24      datetime: {
25        year: 'numeric',
26        month: 'short',
27        day: 'numeric',
28        hour: '2-digit',
29        minute: '2-digit'
30      }
31    };
32
33    return new Intl.DateTimeFormat(
34      i18n.language,
35      formatOptions[format]
36    ).format(new Date(date));
37  };
38
39  return <span>{formatDate(date, format)}</span>;
40}
41
42// Utilisation
43function EventCard({ event }) {
44  return (
45    <div className="event-card">
46      <h3>{event.title}</h3>
47      <DateFormatter date={event.startDate} format="long" />
48      <DateFormatter date={event.startTime} format="time" />
49    </div>
50  );
51}

L'ordre du jour, du mois et de l'année, ainsi que les noms des mois, s'adaptent tout seuls à la langue, sans aucun if.

Les nombres et les devises sont gérés de la même façon par Intl.NumberFormat :

1// NumberFormatter.jsx
2import { useTranslation } from 'react-i18next';
3
4function NumberFormatter({
5  value,
6  type = 'decimal',
7  currency = 'USD',
8  minimumFractionDigits = 0,
9  maximumFractionDigits = 2
10}) {
11  const { i18n } = useTranslation();
12
13  const formatNumber = (value, type) => {
14    const options = {
15      minimumFractionDigits,
16      maximumFractionDigits
17    };
18
19    if (type === 'currency') {
20      options.style = 'currency';
21      options.currency = currency;
22    } else if (type === 'percent') {
23      options.style = 'percent';
24    }
25
26    return new Intl.NumberFormat(i18n.language, options).format(value);
27  };
28
29  return <span>{formatNumber(value, type)}</span>;
30}
31
32// PriceDisplay.jsx
33function PriceDisplay({ price, currency, originalPrice }) {
34  const { t } = useTranslation();
35
36  return (
37    <div className="price-display">
38      <span className="current-price">
39        <NumberFormatter
40          value={price}
41          type="currency"
42          currency={currency}
43        />
44      </span>
45
46      {originalPrice && originalPrice > price && (
47        <span className="original-price">
48          <NumberFormatter
49            value={originalPrice}
50            type="currency"
51            currency={currency}
52          />
53        </span>
54      )}
55
56      {originalPrice && originalPrice > price && (
57        <span className="discount">
58          {t('product.discount', {
59            percent: ((originalPrice - price) / originalPrice * 100).toFixed(0)
60          })}
61        </span>
62      )}
63    </div>
64  );
65}

Le nombre 12345.5 s'écrit 12 345,5 en polonais et 12,345.5 en anglais. La devise ne change pas avec la langue, seule l'écriture change.

Chargement paresseux des traductions

LazyTranslation charge un espace de noms via loadNamespaces seulement quand l'écran en a besoin, et affiche un message en attendant :

1// LazyTranslation.jsx
2import { useState, useEffect } from 'react';
3import { useTranslation } from 'react-i18next';
4
5function LazyTranslation({ namespace, children }) {
6  const { i18n, ready } = useTranslation(namespace, { useSuspense: false });
7  const [isLoading, setIsLoading] = useState(!ready);
8
9  useEffect(() => {
10    if (!ready) {
11      i18n.loadNamespaces(namespace).then(() => {
12        setIsLoading(false);
13      });
14    } else {
15      setIsLoading(false);
16    }
17  }, [namespace, ready, i18n]);
18
19  if (isLoading) {
20    return <div className="loading">Chargement des traductions...</div>;
21  }
22
23  return children;
24}
25
26// Utilisation
27function ProductPage() {
28  return (
29    <LazyTranslation namespace="products">
30      <ProductDetails />
31      <ProductReviews />
32    </LazyTranslation>
33  );
34}

Le drapeau ready indique si les traductions sont arrivées. Cette variante désactive Suspense avec l'option useSuspense: false, car par défaut le hook suspend lui-même le composant.

C'est pourquoi Suspense est plus pratique : useTranslation avec la valeur par défaut useSuspense: true suspend le composant jusqu'au chargement de l'espace de noms :

1// TranslationSuspense.jsx
2import { Suspense } from 'react';
3import { useTranslation } from 'react-i18next';
4
5function TranslationLoader({ namespace, children }) {
6  const { ready } = useTranslation(namespace, { useSuspense: true });
7
8  if (!ready) {
9    throw new Promise((resolve) => {
10      // Simulation du chargement
11      setTimeout(resolve, 100);
12    });
13  }
14
15  return children;
16}
17
18function App() {
19  return (
20    <div>
21      <Suspense fallback={<div>Chargement des traductions...</div>}>
22        <TranslationLoader namespace="common">
23          <Header />
24        </TranslationLoader>
25      </Suspense>
26
27      <Suspense fallback={<div>Chargement du contenu de la page...</div>}>
28        <TranslationLoader namespace="dashboard">
29          <Dashboard />
30        </TranslationLoader>
31      </Suspense>
32    </div>
33  );
34}

Lancer la promesse à la main dans TranslationLoader ne fait qu'illustrer le mécanisme, car le hook suspend le rendu de toute façon. Chaque frontière Suspense se charge indépendamment.

SEO et SSR

Avec le SSR, les traductions doivent être prêtes avant que le HTML ne quitte le serveur, sinon le moteur de recherche verra les clés. L'exemple utilise next-i18next 16 avec le Pages Router, dont tu importes l'API depuis next-i18next/pages :

1// next-i18next.config.js
2module.exports = {
3  i18n: {
4    defaultLocale: 'en',
5    locales: ['en', 'pl', 'es', 'fr', 'de']
6  },
7  localePath: './public/locales',
8  defaultNS: 'common',
9  fallbackLng: {
10    'en-US': ['en'],
11    'pl-PL': ['pl'],
12    default: ['en']
13  }
14};
15
16// pages/_app.js
17import { appWithTranslation } from 'next-i18next/pages';
18import { LanguageProvider } from '../components/LanguageProvider';
19
20function MyApp({ Component, pageProps }) {
21  return (
22    <LanguageProvider>
23      <Component {...pageProps} />
24    </LanguageProvider>
25  );
26}
27
28export default appWithTranslation(MyApp);
29
30// pages/index.js
31import { serverSideTranslations } from 'next-i18next/pages/serverSideTranslations';
32import { useTranslation } from 'next-i18next/pages';
33
34function HomePage() {
35  const { t } = useTranslation('common');
36
37  return (
38    <div>
39      <h1>{t('welcome.title')}</h1>
40      <p>{t('welcome.description')}</p>
41    </div>
42  );
43}
44
45export async function getStaticProps({ locale }) {
46  return {
47    props: {
48      ...(await serverSideTranslations(locale, ['common', 'navigation']))
49    }
50  };
51}
52
53export default HomePage;

serverSideTranslations charge les espaces de noms sur le serveur, et appWithTranslation les transmet à l'application. L'objet i18n de la configuration va aussi dans next.config.js, et Next.js n'y accepte que ses propres champs, comme locales et defaultLocale, c'est pourquoi les options de la bibliothèque (localePath, defaultNS, fallbackLng) se trouvent à côté. Pour l'App Router, la version 16 a une API distincte : getT() dans les Server Components et useT() dans les Client Components.

LocalizedHead traduit les balises meta et indique au moteur de recherche que la page existe en plusieurs langues :

1// LocalizedHead.jsx
2import Head from 'next/head';
3import { useTranslation } from 'next-i18next/pages';
4import { useRouter } from 'next/router';
5
6function LocalizedHead({
7  title,
8  description,
9  keywords,
10  ogImage,
11  canonical
12}) {
13  const { t, i18n } = useTranslation('meta');
14  const router = useRouter();
15
16  const localizedTitle = title ? t(title) : t('default.title');
17  const localizedDescription = description ? t(description) : t('default.description');
18  const localizedKeywords = keywords ? t(keywords) : t('default.keywords');
19
20  const currentUrl = `https://example.com${router.asPath}`;
21  const canonicalUrl = canonical || currentUrl;
22
23  return (
24    <Head>
25      <title>{localizedTitle}</title>
26      <meta name="description" content={localizedDescription} />
27      <meta name="keywords" content={localizedKeywords} />
28      <meta name="language" content={i18n.language} />
29
30      {/* Open Graph */}
31      <meta property="og:title" content={localizedTitle} />
32      <meta property="og:description" content={localizedDescription} />
33      <meta property="og:url" content={currentUrl} />
34      <meta property="og:locale" content={i18n.language} />
35      <meta property="og:image" content={ogImage} />
36
37      {/* Twitter Card */}
38      <meta name="twitter:title" content={localizedTitle} />
39      <meta name="twitter:description" content={localizedDescription} />
40
41      {/* Canonical URL */}
42      <link rel="canonical" href={canonicalUrl} />
43
44      {/* Alternate language versions */}
45      {i18n.options.locales?.map(locale => (
46        <link
47          key={locale}
48          rel="alternate"
49          hrefLang={locale}
50          href={`https://example.com/${locale}${router.asPath}`}
51        />
52      ))}
53    </Head>
54  );
55}

Un lien rel="alternate" avec l'attribut hrefLang désigne chaque version linguistique, et og:locale indique la langue aux réseaux sociaux.

Tests

Dans les tests, tu ne charges pas les fichiers par HTTP. Un render personnalisé enveloppe le composant dans I18nextProvider, et la configuration de test fournit les traductions via resources et fixe lng: 'en' :

1// test-utils.js
2import { render } from '@testing-library/react';
3import { I18nextProvider } from 'react-i18next';
4import i18n from './i18n-test-config';
5
6const AllTheProviders = ({ children }) => {
7  return (
8    <I18nextProvider i18n={i18n}>
9      {children}
10    </I18nextProvider>
11  );
12};
13
14const customRender = (ui, options) =>
15  render(ui, { wrapper: AllTheProviders, ...options });
16
17export * from '@testing-library/react';
18export { customRender as render };
19
20// i18n-test-config.js
21import i18n from 'i18next';
22import { initReactI18next } from 'react-i18next';
23
24i18n.use(initReactI18next).init({
25  lng: 'en',
26  fallbackLng: 'en',
27  defaultNS: 'common',
28  debug: false,
29  interpolation: {
30    escapeValue: false
31  },
32  resources: {
33    en: {
34      common: {
35        'welcome.title': 'Welcome',
36        'button.submit': 'Submit'
37      }
38    },
39    pl: {
40      common: {
41        'welcome.title': 'Witaj'
42      }
43    }
44  }
45});
46
47export default i18n;
48
49// Component.test.js
50import { render, screen } from './test-utils';
51import { act } from '@testing-library/react';
52import i18n from './i18n-test-config';
53import WelcomeComponent from './WelcomeComponent';
54
55describe('WelcomeComponent', () => {
56  test('renders welcome message', () => {
57    render(<WelcomeComponent />);
58
59    expect(screen.getByText('Welcome')).toBeInTheDocument();
60  });
61
62  test('changes language', async () => {
63    const { rerender } = render(<WelcomeComponent />);
64
65    // Changer la langue
66    await act(async () => {
67      await i18n.changeLanguage('pl');
68    });
69
70    rerender(<WelcomeComponent />);
71
72    expect(screen.getByText('Witaj')).toBeInTheDocument();
73  });
74});

C'est la même chaîne use(initReactI18next).init() que dans l'application, simplement sans backend. defaultNS: 'common' désigne l'espace de noms des clés de test, et resources contient les deux langues vérifiées par le test. Le test de changement de langue attend dans act que React rende à nouveau le composant.

Performance

Pour les longues listes, OptimizedTranslation combine memo et useMemo, et useTranslationCache garde les textes prêts dans une map :

1// OptimizedTranslation.jsx
2import { memo, useMemo, useCallback } from 'react';
3import { useTranslation } from 'react-i18next';
4
5const OptimizedTranslation = memo(function OptimizedTranslation({
6  translationKey,
7  values,
8  namespace = 'common'
9}) {
10  const { t } = useTranslation(namespace);
11
12  const translatedText = useMemo(() => {
13    return t(translationKey, values);
14  }, [t, translationKey, values]);
15
16  return <span>{translatedText}</span>;
17});
18
19// TranslationCache.jsx - Custom hook avec cache
20function useTranslationCache() {
21  const { t, i18n } = useTranslation();
22  const cache = useMemo(() => new Map(), [i18n.language]);
23
24  const getCachedTranslation = useCallback((key, options) => {
25    const cacheKey = JSON.stringify({ key, options });
26
27    if (cache.has(cacheKey)) {
28      return cache.get(cacheKey);
29    }
30
31    const translation = t(key, options);
32    cache.set(cacheKey, translation);
33
34    return translation;
35  }, [t, cache]);
36
37  return getCachedTranslation;
38}

Le cache est reconstruit à partir de zéro quand i18n.language change, donc les anciens textes ne fuiront pas dans la nouvelle langue. Mais mesure d'abord, car c'est généralement une optimisation prématurée.

Tu peux isoler les fichiers de traduction dans un bundle séparé ou les charger avec un import() dynamique :

1// webpack.config.js
2module.exports = {
3  optimization: {
4    splitChunks: {
5      cacheGroups: {
6        i18n: {
7          test: /[\\/]locales[\\/]/,
8          name: 'i18n',
9          chunks: 'all'
10        }
11      }
12    }
13  }
14};
15
16// Imports dynamiques pour les traductions
17const loadTranslations = async (language) => {
18  const translations = await import(`../locales/${language}/common.json`);
19  return translations.default;
20};

splitChunks regroupe le dossier locales dans un fichier i18n, et loadTranslations ne récupère qu'une seule langue à la fois.

Bonnes pratiques

Regroupe les clés selon leur place dans l'application, c'est-à-dire les pages, les composants et les actions communes :

1// Bonne structure
2{
3  "pages": {
4    "home": {
5      "title": "Home Page",
6      "meta": {
7        "description": "Welcome to our homepage"
8      }
9    },
10    "about": {
11      "title": "About Us",
12      "sections": {
13        "team": "Our Team",
14        "history": "Company History"
15      }
16    }
17  },
18  "components": {
19    "navigation": {
20      "menu": "Menu",
21      "close": "Close"
22    },
23    "forms": {
24      "validation": {
25        "required": "This field is required",
26        "email": "Please enter a valid email"
27      }
28    }
29  },
30  "common": {
31    "actions": {
32      "save": "Save",
33      "cancel": "Cancel",
34      "delete": "Delete"
35    }
36  }
37}

Les mots communs, comme Save ou Cancel, se trouvent alors à un seul endroit : common.actions.

Un hook personnalisé limite t() aux clés de l'interface TranslationKeys, donc une faute de frappe arrête la compilation :

1// types/i18n.ts
2export interface TranslationKeys {
3  'welcome.title': string;
4  'welcome.description': string;
5  'navigation.home': string;
6  'navigation.about': string;
7  'forms.name.label': string;
8  'forms.email.label': string;
9}
10
11// Custom hook avec typage
12import { useTranslation as useI18nTranslation } from 'react-i18next';
13
14export function useTranslation(namespace?: string) {
15  const { t, ...rest } = useI18nTranslation(namespace);
16
17  return {
18    t: (key: keyof TranslationKeys, options?: any) => t(key, options),
19    ...rest
20  };
21}
22
23// Composants avec typage
24interface TranslatedTextProps {
25  translationKey: keyof TranslationKeys;
26  values?: Record<string, any>;
27}
28
29function TranslatedText({ translationKey, values }: TranslatedTextProps) {
30  const { t } = useTranslation();
31  return <span>{t(translationKey, values)}</span>;
32}

La documentation de react-i18next décrit aussi un typage complet via l'interface CustomTypeOptions, qui déduit les clés des fichiers JSON. Dans un projet plus grand, je te recommande cette méthode.

Tu mettras en pratique l'organisation des traductions, la performance et les tests de scénarios linguistiques dans l'exercice, en construisant un tableau de bord spatial multilingue.

Retiens : le composant ne connaît que des clés, et les fichiers de traduction sont les dictionnaires du traducteur de bord - tu changes de dictionnaire, et le vaisseau parle une nouvelle langue.

Code de cette leçon : App.jsx
1import React, { useState, createContext, useContext } from 'react';
2
3// Simulation d'une bibliothèque i18n - système de traductions
4const translations = {
5  fr: {
6    welcome: 'Bienvenue à la station spatiale !',
7    language: 'Langue',
8    mission: 'Mission actuelle',
9    missionDesc: 'Exploration de nouvelles galaxies',
10    crew: 'Équipage',
11    crewMembers: '{count} membres',
12    status: 'Statut du système',
13    online: 'En ligne',
14    fuel: 'Carburant : {level}%',
15    date: 'Date stellaire',
16  },
17  en: {
18    welcome: 'Welcome to the space station!',
19    language: 'Language',
20    mission: 'Current mission',
21    missionDesc: 'Exploring new galaxies',
22    crew: 'Crew',
23    crewMembers: '{count} members',
24    status: 'System status',
25    online: 'Online',
26    fuel: 'Fuel: {level}%',
27    date: 'Star date',
28  },
29  de: {
30    welcome: 'Willkommen auf der Raumstation!',
31    language: 'Sprache',
32    mission: 'Aktuelle Mission',
33    missionDesc: 'Erforschung neuer Galaxien',
34    crew: 'Besatzung',
35    crewMembers: '{count} Mitglieder',
36    status: 'Systemstatus',
37    online: 'Online',
38    fuel: 'Treibstoff: {level}%',
39    date: 'Sternendatum',
40  },
41};
42
43// Contexte i18n
44const I18nContext = createContext(null);
45
46function I18nProvider({ children }) {
47  const [locale, setLocale] = useState('fr');
48
49  const t = (key, params = {}) => {
50    let text = translations[locale]?.[key] || key;
51    Object.entries(params).forEach(([k, v]) => {
52      text = text.replace('{' + k + '}', v);
53    });
54    return text;
55  };
56
57  return (
58    <I18nContext.Provider value={{ locale, setLocale, t }}>
59      {children}
60    </I18nContext.Provider>
61  );
62}
63
64function useTranslation() {
65  return useContext(I18nContext);
66}
67
68function LanguageSwitcher() {
69  const { locale, setLocale } = useTranslation();
70  const languages = [
71    { code: 'fr', flag: 'FR', label: 'Français' },
72    { code: 'en', flag: 'EN', label: 'English' },
73    { code: 'de', flag: 'DE', label: 'Deutsch' },
74  ];
75
76  return (
77    <div style={{ display: 'flex', gap: '8px' }}>
78      {languages.map(lang => (
79        <button
80          key={lang.code}
81          onClick={() => setLocale(lang.code)}
82          style={{
83            padding: '8px 16px',
84            border: locale === lang.code ? '2px solid #00d4ff' : '1px solid #415a77',
85            borderRadius: '8px',
86            background: locale === lang.code ? '#1b2838' : 'transparent',
87            color: locale === lang.code ? '#00d4ff' : '#778da9',
88            cursor: 'pointer',
89            fontWeight: locale === lang.code ? 'bold' : 'normal',
90          }}
91        >
92          {lang.flag} {lang.label}
93        </button>
94      ))}
95    </div>
96  );
97}
98
99function SpaceStationDashboard() {
100  const { t, locale } = useTranslation();
101  const crewCount = 7;
102  const fuelLevel = 82;
103  const starDate = new Date().toLocaleDateString(locale, {
104    year: 'numeric', month: 'long', day: 'numeric',
105  });
106
107  return (
108    <div style={{ background: '#0d1b2a', minHeight: '100vh', padding: '24px', color: '#e0e1dd', fontFamily: 'monospace' }}>
109      <div style={{ maxWidth: '600px', margin: '0 auto' }}>
110        <h1 style={{ color: '#00d4ff', marginBottom: '8px' }}>{t('welcome')}</h1>
111
112        <div style={{ marginBottom: '24px' }}>
113          <h3 style={{ color: '#778da9', marginBottom: '8px' }}>{t('language')}</h3>
114          <LanguageSwitcher />
115        </div>
116
117        <div style={{ display: 'grid', gap: '16px' }}>
118          <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
119            <h3 style={{ color: '#00d4ff', margin: '0 0 8px' }}>{t('mission')}</h3>
120            <p style={{ margin: 0, color: '#e0e1dd' }}>{t('missionDesc')}</p>
121          </div>
122
123          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
124            <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
125              <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('crew')}</h4>
126              <p style={{ color: '#00d4ff', fontSize: '20px', margin: 0 }}>
127                {t('crewMembers', { count: crewCount })}
128              </p>
129            </div>
130            <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
131              <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('status')}</h4>
132              <p style={{ color: '#00ff88', fontSize: '20px', margin: 0 }}>{t('online')}</p>
133            </div>
134          </div>
135
136          <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
137            <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('fuel', { level: fuelLevel })}</h4>
138            <div style={{ background: '#0d1b2a', borderRadius: '8px', height: '20px', overflow: 'hidden' }}>
139              <div style={{ width: fuelLevel + '%', height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)', borderRadius: '8px' }} />
140            </div>
141          </div>
142
143          <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
144            <h4 style={{ color: '#778da9', margin: '0 0 4px' }}>{t('date')}</h4>
145            <p style={{ color: '#e0e1dd', fontSize: '18px', margin: 0 }}>{starDate}</p>
146          </div>
147        </div>
148      </div>
149    </div>
150  );
151}
152
153export default function App() {
154  return (
155    <I18nProvider>
156      <SpaceStationDashboard />
157    </I18nProvider>
158  );
159}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. À quoi sert le hook useTranslation de la bibliothèque react-i18next ?

  2. 2. Comment react-i18next gère-t-il la pluralisation (par exemple « 1 élément » vs « 5 éléments ») ?

Tâches pratiques dans le jeu

  • Clique dans l'ordre

    Clique sur les éléments dans l'ordre d'initialisation d'i18next dans une application React :

  • Éditeur de code

    Complète une mini version de la fonction t() d'i18next dans un tableau de bord spatial (FR, EN, PL). ___BLANK1___ : passe à new Intl.PluralRules(...) le code de la langue actuelle, pour que le polonais choisisse les formes one, few et many, et le français one, many et other. ___BLANK2___ : remplace {{name}} et {{count}} par la valeur de l'objet options (name est le nom de la variable entre les doubles accolades, par ex. count). ___BLANK3___ : un bouton de langue enregistre son code (fr, en ou pl) dans l'état. Dans l'aperçu : 1 mission active, puis 2 missions actives ; après le passage à PL, tu verras 2 aktywne misje, puis 5 aktywnych misji après trois ajouts.

Articles utiles