Internationalisation frontend (React)¶
Le frontend React utilise un système d'i18n maison, sans dépendance externe (pas de react-intl, pas de i18next). L'implémentation tient dans un seul fichier (I18nContext.jsx) d'une soixantaine de lignes et suffit à tous les besoins du shop et du parcours pédagogique.
Sources
frontend/src/i18n/I18nContext.jsx, frontend/src/i18n/fr.json, frontend/src/i18n/en.json
Pourquoi pas react-intl ou i18next ?¶
PerfShop n'a pas besoin des fonctionnalités avancées de ces librairies :
- Pas de pluralisation complexe (les rares cas sont gérés à la main avec un ternaire)
- Pas de formats date / nombre localisés (les dates suivent déjà la locale du navigateur via
toLocaleDateString()) - Pas de chargement dynamique à la volée (les deux langues sont importées statiquement au build)
- Pas de détection automatique (la langue est fixée au déploiement via
VITE_LANG)
Un hook useT() minimal et deux fonctions t() / tErr() couvrent 100 % des besoins. Le gain : zéro poids supplémentaire dans le bundle, une lisibilité totale, et un contrôle complet sur le comportement.
Fichiers¶
frontend/src/i18n/
├── I18nContext.jsx ← Provider + hook useT()
├── fr.json ← Dictionnaire français (~23 KB, ~400 clés)
└── en.json ← Dictionnaire anglais (~21 KB, ~400 clés)
Chaque JSON est plat — pas de structure imbriquée :
{
"nav.logo": "PerfShop",
"nav.catalog": "Catalogue",
"nav.orders": "Mes commandes",
"nav.account": "Mon compte",
"nav.logout": "Déconnexion",
"nav.login": "Connexion",
"nav.cartCount": "Panier ({count})",
"products.title": "Nos produits",
"products.empty": "Aucun produit dans cette catégorie",
"products.addToCart": "Ajouter au panier",
"api.error.loadProducts": "Impossible de charger les produits",
"api.error.cartEmpty": "Votre panier est vide"
}
La structure plate est un choix délibéré : elle rend le lookup trivial (dict[key]), simplifie la comparaison FR/EN, et évite la complexité des chemins imbriqués.
I18nContext.jsx¶
Le provider est court et lisible :
import React, { createContext, useContext, useMemo } from 'react';
import fr from './fr.json';
import en from './en.json';
const dictionaries = { fr, en };
const I18nContext = createContext({
t: (k) => k, tErr: (k) => k, lang: 'fr', DATE_LOCALE: 'fr-FR'
});
export function I18nProvider({ children }) {
const lang = (import.meta.env.VITE_LANG || 'fr').toLowerCase();
const safeLocale = lang === 'en' ? 'en-GB' : `${lang}-${lang.toUpperCase()}`;
const value = useMemo(() => {
const dict = dictionaries[lang] || dictionaries.fr;
const t = (key, replacements) => {
let text = dict[key] ?? dictionaries.fr[key] ?? key;
if (replacements) {
Object.entries(replacements).forEach(([k, v]) => {
text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v));
});
}
return text;
};
const tErr = (msg) => {
if (!msg) return '';
const resolved = dict[msg] ?? dictionaries.fr[msg];
return resolved !== undefined ? resolved : msg;
};
return { t, tErr, lang, DATE_LOCALE: safeLocale };
}, [lang, safeLocale]);
React.useEffect(() => {
document.documentElement.lang = lang;
}, [lang]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useT() {
return useContext(I18nContext);
}
Fonctionnement en détail¶
Détermination de la langue¶
import.meta.env.VITE_LANG est injectée par Vite au build-time, et éventuellement réécrite au runtime par env-inject.sh dans le conteneur Nginx. La valeur par défaut est fr. Elle est convertie en minuscules pour éviter toute erreur de saisie.
Choix du dictionnaire¶
Si la langue demandée n'existe pas (ex : VITE_LANG=xx), le fallback est automatiquement le dictionnaire français.
Fonction t()¶
Trois niveaux de fallback :
- Le dictionnaire de la langue active
- Le dictionnaire français en fallback
- La clé elle-même en dernier recours (ne casse jamais, facilite le debug)
La substitution des placeholders utilise le format {nom} :
Les placeholders sont nommés (et non numérotés comme dans le backend MessageFormat). C'est une convention différente mais tout aussi lisible : {count}, {email}, {status}.
Fonction tErr()¶
Cette fonction auxiliaire résout les messages d'erreur qui peuvent être soit une clé i18n (issue du client API), soit une chaîne serveur brute :
const tErr = (msg) => {
if (!msg) return '';
const resolved = dict[msg] ?? dictionaries.fr[msg];
return resolved !== undefined ? resolved : msg;
};
Le client services/api.js lève des Error dont le message est souvent une clé comme 'api.error.loadProducts'. tErr() essaie de résoudre cette clé ; si elle n'existe pas dans le dictionnaire, elle considère que c'est déjà un message brut retourné par le serveur et l'affiche tel quel. Cela permet au frontend de gérer les deux cas uniformément.
Usage typique :
try {
const data = await getProducts();
setProducts(data);
} catch (err) {
setError(tErr(err.message));
}
Mise à jour de l'attribut <html lang>¶
Un useEffect synchronise l'attribut lang de la balise <html> avec la langue active. C'est important pour :
- L'accessibilité — les lecteurs d'écran utilisent cet attribut pour déterminer la langue à prononcer
- Le SEO — les moteurs de recherche l'utilisent pour indexer correctement les versions linguistiques
- Le rendu des polices — certaines polices ont des glyphes différents selon la langue (notamment pour les langues asiatiques)
Format de date¶
Le contexte expose aussi une constante DATE_LOCALE déterminée depuis la langue :
lang |
DATE_LOCALE |
Exemple de date |
|---|---|---|
fr |
fr-FR |
15 janvier 2026 |
en |
en-GB |
15 January 2026 |
es |
es-ES |
15 de enero de 2026 |
de |
de-DE |
15. Januar 2026 |
it |
it-IT |
15 gennaio 2026 |
Le choix de en-GB (et non en-US) pour l'anglais est délibéré : il donne un format de date jour/mois/année cohérent avec le reste des locales latines, au lieu du format américain mois/jour/année qui pourrait dérouter les utilisateurs européens.
Les pages consomment cette constante pour formater les dates :
const { t, DATE_LOCALE } = useT();
const dateStr = new Date(order.createdAt).toLocaleDateString(DATE_LOCALE);
Hook useT()¶
Le hook useT() est importé depuis '../i18n/I18nContext' dans chaque composant qui a besoin de traductions :
import { useT } from '../i18n/I18nContext';
export default function Cart({ cart }) {
const { t, tErr, DATE_LOCALE } = useT();
return (
<div>
<h1>{t('cart.title')}</h1>
{cart.length === 0 ? (
<p>{t('cart.empty')}</p>
) : (
<p>{t('cart.itemCount', { count: cart.length })}</p>
)}
</div>
);
}
C'est tout. Pas de composant <Trans>, pas de HOC, pas de bag de traductions à passer en prop.
Symétrie FR/EN¶
Les deux JSON doivent contenir exactement les mêmes clés. Un diff rapide :
jq 'keys | sort' frontend/src/i18n/fr.json > /tmp/fr.keys
jq 'keys | sort' frontend/src/i18n/en.json > /tmp/en.keys
diff /tmp/fr.keys /tmp/en.keys
Ce diff doit être vide. Toute divergence est une dérive à corriger.
Au moment de cette rédaction, la symétrie est de ~400 clés en FR / ~400 clés en EN.
Ajouter une clé¶
- Ajouter la clé en français dans
frontend/src/i18n/fr.json - Ajouter la même clé en anglais dans
en.json - Rebuild (Vite HMR prend en charge automatiquement)
- Utiliser depuis un composant :
const { t } = useT(); ... {t('ma.nouvelle.cle')}
Ajouter une nouvelle langue¶
Trois étapes :
- Créer
frontend/src/i18n/<lang>.jsonen copiantfr.jsonet en traduisant les valeurs - L'importer en tête de
I18nContext.jsx: - Déployer avec
VITE_LANG=es
Aucun autre fichier à toucher. Le DATE_LOCALE est calculé automatiquement selon la convention {lang}-{LANG.upper}.
Ce qui reste non traduit¶
Quelques éléments restent intentionnellement en anglais ou codés en dur :
- Les IDs CSS et classes — évidemment
- Les logs console — les
console.logetconsole.errorsont en anglais pour être compréhensibles par l'équipe de développement internationale - Les commentaires de code — majoritairement en français car l'auteur est francophone, mais certains sont en anglais pour les parties destinées à être partagées
Cette règle est pragmatique : traduire ce qui est visible de l'utilisateur, laisser ce qui relève du code tel quel.
Voir aussi¶
- Vue d'ensemble i18n
- Internationalisation backend
- Frontend e-commerce — où l'i18n est consommée