react-intl vs react-i18next vs LinguiJS — RHCLOUD-52006
We investigated whether react-intl, react-i18next, and LinguiJS support the following scenario: app strings stay in the user's chosen locale (e.g. French), but date/number/currency formatting uses a different locale (e.g. Japanese). 23 of 25 claims were verified through documentation, source code analysis, and adversarial cross-checking. All claims were validated with a runnable Vitest suite (31 tests, all passing).
| Capability | react-intl | react-i18next (native) | react-i18next + ICU plugin | LinguiJS | Intl API |
|---|---|---|---|---|---|
| Dates in locale B | ❌ No per-call override | ✅ formatParams locale | ❌ formatParams ignored | ⚠️ Global only | ✅ Always |
| Numbers/currency in locale C | ❌ No per-call override | ✅ formatParams locale | ❌ formatParams ignored | ⚠️ Same global | ✅ Always |
| Different locales per value | ❌ Impossible | ✅ per-value formatParams | ❌ formatParams ignored | ❌ Impossible | ✅ Always |
| ICU MessageFormat | ✅ Native | ❌ Requires i18next-icu plugin | ✅ Via plugin | ✅ Native | N/A |
| Plural rules per locale | ❌ Follows IntlProvider | ✅ lng option | ✅ Via ICU | ❌ Follows activate() | ✅ Always |
| Relative time override | ❌ No per-call override | ✅ formatParams locale | ❌ formatParams ignored | ❌ No built-in | ✅ Always |
| List formatting override | ❌ No per-call override | ✅ formatParams locale | ❌ formatParams ignored | ❌ No built-in | ✅ Always |
| Collation/sorting | N/A | N/A | N/A | N/A | ✅ Intl.Collator |
The i18next-icu plugin enables full ICU MessageFormat support
({count, plural, ...}, {date, date},
{amount, number, ::currency/EUR}), but it completely replaces
i18next's native interpolation engine. The mixed-locale formatParams.locale feature
works through i18next's native Formatter — which the ICU plugin bypasses.
The existing react-i18next tenants in HCC (OpenShift Console, Assisted Installer, Portal Case Management,
Connect) do NOT use the ICU plugin. They use i18next's native format. If the ADR's ICU
MessageFormat mandate applies to them, they would need to add the i18next-icu plugin —
which would disable the mixed-locale capability.
You get ICU or mixed-locale formatting — not both.
// With i18next-icu active, app locale is 'fr'
// ICU formatting works ✅
t('report', { date: someDate, amount: 1234567.89 })
// → "Rapport du 15 juin 2025 — 1 234 567,89 €"
// formatParams locale is SILENTLY IGNORED ❌
t('report', {
date: someDate,
amount: 1234567.89,
formatParams: {
date: { locale: 'ja-JP' }, // ← ignored
amount: { locale: 'ja-JP' }, // ← ignored
},
})
// → "Rapport du 15 juin 2025 — 1 234 567,89 €" (still French, not Japanese)
The output is identical with or without formatParams — the locale overrides have zero effect.
Additionally, i18next's native {{value}} interpolation syntax is treated as literal text when the ICU plugin is active.
react-i18next (v21.3.0+) supports mixed-locale formatting through formatParams
with per-value locale overrides when using i18next's native interpolation
(not the ICU plugin). The app locale controls which translation string
is selected; formatParams.locale controls how each interpolated value is formatted.
// App locale is 'fr'. Translation catalog (native i18next format):
// "order_summary": "Résumé de la commande : {{date, datetime}} — {{amount, number}}"
const result = t('order_summary', {
date: someDate,
amount: 1234567.89,
formatParams: {
date: {
year: 'numeric',
month: 'long',
day: 'numeric',
locale: 'ja-JP', // ← date formatted in Japanese
},
amount: {
style: 'currency',
currency: 'JPY',
locale: 'ja-JP', // ← number formatted in Japanese
},
},
});
// Output: "Résumé de la commande : 2025年6月15日 — ¥1,234,568"
// ^^^^^^^^^^^^^^^^^^^^^^^^ French string
// ^^^^^^^^^^^^ ^^^^^^^^^^ Japanese formatting
// "invoice": "Facture du {{date, datetime}} pour {{amount, number}} (TVA : {{tax, number}})"
const result = t('invoice', {
date: someDate,
amount: 12345.67,
tax: 2469.13,
formatParams: {
date: {
year: 'numeric', month: 'long', day: 'numeric',
locale: 'ja-JP', // ← Japanese date
},
amount: {
style: 'currency', currency: 'USD',
locale: 'en-US', // ← US dollars
},
tax: {
style: 'currency', currency: 'EUR',
locale: 'de-DE', // ← German euros
},
},
});
// Output: "Facture du 2025年6月15日 pour $12,345.67 (TVA : 2.469,13 €)"
// French text JP date US dollars DE euros
// Same key, no locale overrides — everything uses fr-FR
const result = t('order_summary', {
date: someDate,
amount: 1234567.89,
formatParams: {
date: { year: 'numeric', month: 'long', day: 'numeric' },
amount: { style: 'currency', currency: 'EUR' },
},
});
// Output: "Résumé de la commande : 15 juin 2025 — 1 234 567,89 €"
// All French — default behavior preserved
function OrderSummary({ date, amount }) {
const { t } = useTranslation();
return (
<span>
{t('order_summary', {
date,
amount,
formatParams: {
date: { year: 'numeric', month: 'long', day: 'numeric', locale: 'ja-JP' },
amount: { style: 'currency', currency: 'JPY', locale: 'ja-JP' },
},
})}
</span>
);
}
Locale is set once at IntlProvider/createIntl level. None of the
formatting functions (formatDate, formatNumber,
formatRelativeTime, formatList) or Formatted* components
accept a locale parameter. The TypeScript types confirm this — FormatDateOptions
has no locale field. GitHub issue
#1674 requested this feature but
it was never implemented.
Workaround: nested IntlProvider can render values in a different locale, but only for
component-rendered values — not within a single translated string via formatMessage.
i18n.activate('fr', ['ja-JP']) separates UI locale from formatting locale, but it's
all-or-nothing — you can't use ja-JP for dates and de-DE for numbers simultaneously.
i18n.date() and i18n.number() accept no per-call locale parameter. Both
methods are deprecated — LinguiJS recommends using Intl.DateTimeFormat/Intl.NumberFormat directly.
Every Intl constructor (DateTimeFormat, NumberFormat,
RelativeTimeFormat, ListFormat, PluralRules,
Collator) accepts locale as its first argument. Any app can bypass library limitations
by calling these directly — this is what LinguiJS officially recommends.
formatParams locale override requires i18next v21.3.0+ (Formatter class) and must NOT use the i18next-icu plugini18next-icu plugin completely replaces i18next's native interpolation — {{value}} syntax becomes literal text, formatParams locale overrides are silently ignoredi18n.date()/i18n.number() are deprecated and will be removedIntl.Collator31 tests across 5 files, all passing:
tests/
├── react-intl.test.tsx 5 tests — confirms no per-call locale override
├── react-i18next.test.tsx 5 tests — confirms formatParams locale works (native format)
├── react-i18next-icu.test.tsx 7 tests — confirms ICU works but formatParams ignored
├── lingui.test.tsx 5 tests — confirms global-only locale split
└── intl-api.test.ts 9 tests — confirms Intl API always supports per-call locale
Run with:
npm install && npm test