Internationalizing a React App: Locale, Translation, Formatting, and RTL
Learn the core concepts behind internationalizing a React application — locale detection and switching, translation and message interpolation, pluralization, and locale-aware number, date, and currency formatting — plus when to reach for an i18n library instead of hand-rolling the plumbing.
Internationalization (i18n) is the work of making an app capable of being adapted to different languages and regions without code changes, and localization (l10n) is actually doing that adaptation for a specific locale; a React app that hardcodes English strings and US date formats directly in JSX has no seam for either, so i18n has to be designed in from the component structure up.
Locale and language switching
A locale identifies both a language and a region — 'en-US' and 'en-GB' are both English but differ in date format, spelling, and sometimes vocabulary; 'fr-FR' and 'fr-CA' are both French but differ in number formatting conventions. Locale strings follow the BCP 47 format (language subtag, optional region subtag, e.g. pt-BR, zh-Hant-TW), and this is the identifier that both the browser's built-in Intl APIs and third-party i18n libraries key their formatting and translation lookups on.
A React app typically stores the active locale in some form of shared state — Context, a state management library, or a URL segment (e.g. /fr/about) — so any component can read it and re-render with the right language when it changes. The initial locale is usually detected from navigator.language or navigator.languages (an ordered preference list the browser exposes) or from a previously saved user preference, with a sensible fallback locale (often 'en') when nothing matches a supported language.
Locale switching should update not just visible text but everything locale-dependent — number formatting, date formatting, pluralization rules, and text direction all need to re-derive from the new locale, which is exactly why centralizing locale in one piece of shared state (rather than passing a raw language string around ad hoc) matters: every consumer re-renders correctly when it changes.
Translation, message interpolation, and pluralization
Translation in an i18n system usually works by looking up a key (not the source-language text itself) in a per-locale dictionary of messages — e.g. t('greeting') resolves to 'Hello' in the en dictionary and 'Bonjour' in the fr dictionary. Keying by an identifier rather than the literal English string avoids ambiguity when the same English phrase needs different translations in different contexts, and it's what lets translators work from the dictionary files without touching component code.
Message interpolation lets a translated string embed dynamic values — t('welcome', { name: 'Amara' }) might resolve to a template like '{name}, bienvenue !' in French, with the placeholder substituted after the translation lookup. This has to be done carefully: string concatenation like 'Hello, ' + name breaks down across languages where word order or grammar around the inserted value differs, so translated strings need genuine placeholder syntax that a translator can reposition within the sentence.
Pluralization is a notably hard problem because languages don't all follow English's simple singular/plural split. English has two plural forms (1 item / 2 items), but some languages have none, some have three, and Arabic has six distinct plural categories (zero, one, two, few, many, other) with different grammatical rules for each. The CLDR (Unicode Common Locale Data Repository) plural rules define these categories per locale, and both Intl.PluralRules (built into JavaScript) and i18n libraries use CLDR data to pick the right category for a given count and locale, rather than a naive count === 1 check.
Number, date, and currency formatting with the Intl API
The browser's built-in Intl namespace provides locale-aware formatting without any library: Intl.NumberFormat handles numbers and currency, and Intl.DateTimeFormat handles dates and times. These respect locale conventions automatically — 1234.5 formats as '1,234.5' in en-US but '1.234,5' in de-DE (comma and period swapped), and a date like new Date(2026, 0, 15) formats as '1/15/2026' in en-US but '15/1/2026' in en-GB and '2026年1月15日' in ja-JP.
Intl.NumberFormat with { style: 'currency', currency: 'JPY' } formats a value as currency using the correct symbol, decimal precision (JPY has no minor unit, so it shows no decimal places, while USD shows two), and symbol placement for the given locale — critically, the currency code (which currency) and the locale (how to format it) are independent parameters, since the same USD amount is formatted differently for an en-US vs. a de-DE reader.
Because these Intl constructors are relatively expensive to create, a common performance practice is to memoize the formatter instance (e.g. with useMemo, keyed on locale and options) rather than constructing a new Intl.NumberFormat or Intl.DateTimeFormat on every render.
Right-to-left (RTL) layout and i18n library awareness
Languages like Arabic, Hebrew, Farsi, and Urdu are written right-to-left, which affects not just text alignment but the entire layout direction — navigation order, icon mirroring (a 'back' arrow should point right instead of left), and the reading order of a row of elements. Setting the dir attribute (dir="rtl") on the html or a container element flips CSS logical properties (margin-inline-start instead of margin-left, for example) automatically, which is why modern CSS favors logical properties over physical left/right ones for anything that needs to support RTL.
React itself doesn't need special handling for RTL beyond setting the dir attribute and using logical CSS properties consistently; the harder part is usually auditing component styles that were written with hardcoded left/right assumptions (position: absolute; left: 0, for instance) and converting them to their logical equivalents (inset-inline-start: 0) so the whole layout mirrors correctly when the locale switches to Arabic or Hebrew.
For any app beyond a handful of hardcoded strings, ECOSYSTEM: reaching for an established i18n library — react-i18next (built on i18next), react-intl (part of FormatJS), or LinguiJS are the common choices in the React ecosystem — is almost always the right call over hand-rolling the dictionary-lookup-plus-interpolation machinery yourself. These libraries handle CLDR-correct pluralization, nested/namespaced translation keys, lazy-loading per-locale message bundles, ICU MessageFormat syntax for interpolation and pluralization in one message format, and integrate with translation-management platforms that professional translators actually use, none of which is worth re-implementing from scratch in a real product.
Locale-aware formatting with the built-in Intl API
Intl.NumberFormat and Intl.DateTimeFormat are built into the JS engine — no library or network request needed for basic formatting. Currency code and locale are independent: the same currency formats differently depending on the reader's locale. Intl.PluralRules is what CLDR-aware i18n libraries use under the hood to pick a plural category correctly per locale.
const price = 1234.5;
const date = new Date(2026, 0, 15);
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(price);
// "$1,234.50"
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(price);
// "1.234,50 €"
new Intl.DateTimeFormat("en-GB", { dateStyle: "long" }).format(date);
// "15 January 2026"
new Intl.PluralRules("ar-EG").select(3);
// "few" — Arabic has distinct plural categories English lacksCode Examples
The provider centralizes locale state and memoizes locale-aware formatting functions so consumers don't recreate Intl formatters on every render; it also derives the dir attribute from locale so RTL layout follows automatically when the locale switches to Arabic.
<div dir={locale === "ar-EG" ? "rtl" : "ltr"}>{children}</div>
Intl.PluralRules.select(count) returns the CLDR plural category ('zero' | 'one' | 'two' | 'few' | 'many' | 'other') appropriate for the given locale and count, which the message templates key on instead of a naive count === 1 check — correctly handling Arabic's six-way plural system as well as English's two-way one.
Common mistake: A naive count === 1 ? 'item' : 'items' check only works for English-style two-form pluralization and silently produces grammatically wrong output for languages with more (or fewer) plural categories.
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
type Locale = "en-US" | "fr-FR" | "ar-EG";
interface LocaleContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
formatCurrency: (amount: number, currency: string) => string;
formatDate: (date: Date) => string;
}
const LocaleContext = createContext<LocaleContextValue | null>(null);
function LocaleProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState<Locale>("en-US");
const value = useMemo<LocaleContextValue>(() => {
const numberFormatter = new Intl.NumberFormat(locale, { style: "currency", currency: "USD" });
const dateFormatter = new Intl.DateTimeFormat(locale, { dateStyle: "medium" });
return {
locale,
setLocale,
formatCurrency: (amount, currency) =>
new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount),
formatDate: (date) => dateFormatter.format(date),
};
}, [locale]);
return (
<LocaleContext.Provider value={value}>
<div dir={locale === "ar-EG" ? "rtl" : "ltr"}>{children}</div>
</LocaleContext.Provider>
);
}
function useLocale() {
const
…const pluralRules: Record<string, Record<string, string>> = {
"en-US": { one: "{count} item", other: "{count} items" },
"ar-EG": {
zero: "لا عناصر",
one: "عنصر واحد",
two: "عنصران",
few: "{count} عناصر",
many: "{count} عنصرًا",
other: "{count} عنصر",
},
};
function formatItemCount(count: number, locale: string): string {
const category = new Intl.PluralRules(locale).select(count);
const template = pluralRules[locale]?.[category] ?? `${count} items`;
return template.replace("{count}", String(count));
}
function ItemCount({ count }: { count: number }) {
const { locale } = useLocale();
return <span>{formatItemCount(count, locale)}</span>;
}How a translated, interpolated message resolves
Translation is a two-step process — look up the key in the active locale's dictionary, then interpolate dynamic values into the resulting template — and locale also drives layout direction independently of the text itself.
source key: "welcome"
|
v locale = "fr-FR"
dictionary lookup -> "{name}, bienvenue !"
|
v interpolate { name: "Amara" }
final string -> "Amara, bienvenue !"
^ locale = "ar-EG" instead
| dictionary lookup -> "مرحبًا ، {name}"
| + dir="rtl" on the container so layout mirrors tooImportant Rules
Key translations by a stable identifier (e.g. 'welcome'), never by the literal source-language string, so the same English phrase can map to different translations in different contexts.
Never build translated sentences by concatenating strings around a variable — use genuine placeholder interpolation so translators can reposition the value for their language's grammar.
Use Intl.PluralRules (or a library built on CLDR data) for pluralization instead of a two-way singular/plural check — many languages have more or fewer plural categories than English.
Memoize Intl.NumberFormat / Intl.DateTimeFormat instances (e.g. with useMemo) rather than constructing them fresh on every render.
Prefer CSS logical properties (margin-inline-start, inset-inline-start) over physical left/right so layouts mirror correctly under dir="rtl" without extra overrides.
For any real product, use an established i18n library (react-i18next, react-intl/FormatJS, LinguiJS) rather than hand-rolling dictionary lookup, interpolation, and pluralization.
Where this shows up
Any product with an international audience — e-commerce checkout flows that must show correct currency and tax formatting per region, SaaS dashboards with dates and numbers localized for enterprise customers worldwide, or a site whose Arabic/Hebrew edition needs proper RTL layout — relies on exactly this stack: centralized locale state, Intl-based formatting, CLDR-correct pluralization via a library, and CSS logical properties for mirrored layout.
const label = count === 1 ? ${count} item : ${count} items;
const category = new Intl.PluralRules(locale).select(count); const label = pluralTemplates[locale][category].replace("{count}", String(count));
A hardcoded singular/plural check only reflects English's two-form pluralization. Locales like Arabic (six forms), Polish (three, with complex rules), or Japanese (no plural distinction at all) need the CLDR-driven category from Intl.PluralRules to render grammatically correct text.
const message = "You have " + count + " new messages";
const message = t("newMessagesCount", { count });
Concatenating a translated fragment around a variable assumes English word order. In many languages the word order around the count differs, or the sentence needs different grammatical agreement depending on the count — a real translation-key-plus-interpolation system handles this per locale; raw concatenation cannot.
[multiple-choice] Why should translated strings be keyed by an identifier like 'welcome' rather than by the literal English source text? A. It makes the JavaScript bundle smaller B. It avoids ambiguity when the same English phrase needs different translations in different contexts, and lets translators work from dictionary files C. React requires all translated strings to have non-English keys D. It's required for Intl.NumberFormat to work
B. It avoids ambiguity when the same English phrase needs different translations in different contexts, and lets translators work from dictionary files — Keying by a stable identifier decouples the lookup from the source-language wording, so the same English phrase can map to different translations depending on context, and translators can work entirely from the per-locale dictionary files without touching component code.
[true-false] A simple count === 1 ? 'item' : 'items' check correctly handles pluralization for all languages. A. True B. False
B. False — This only correctly models English's two-form plural system. Many languages have more categories (Arabic has six: zero, one, two, few, many, other) or fewer (Japanese has none), so CLDR-based pluralization via Intl.PluralRules or an i18n library is needed for correctness.
[multiple-choice] What does setting dir="rtl" on a container element do, when combined with CSS logical properties? A. It only changes text-align to right; layout direction is unaffected B. It flips the entire layout direction, so logical properties like margin-inline-start resolve to the visually correct side automatically C. It disables the Intl API for that subtree D. It has no effect unless an i18n library is also installed
B. It flips the entire layout direction, so logical properties like margin-inline-start resolve to the visually correct side automatically — dir="rtl" changes the writing direction for that subtree, and CSS logical properties (margin-inline-start, inset-inline-start, etc.) automatically resolve to the correct physical side under that direction, which is why logical properties are preferred over hardcoded left/right values in RTL-aware layouts.
Write a formatPrice(amount: number, currency: string, locale: string) function using Intl.NumberFormat that correctly formats 1234.5 as currency for three different locale/currency pairs: ('en-US', 'USD'), ('de-DE', 'EUR'), and ('ja-JP', 'JPY'), and explain in a comment why the JPY output has no decimal places.
function formatPrice(amount: number, currency: string, locale: string): string { return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount); } // formatPrice(1234.5, "USD", "en-US") -> "$1,234.50" // formatPrice(1234.5, "EUR", "de-DE") -> "1.234,50 €" // formatPrice(1234.5, "JPY", "ja-JP") -> "¥1,235" (JPY has 0 minor units per ISO 4217, so Intl rounds and omits decimals)
Pass { style: 'currency', currency } as the options object to Intl.NumberFormat(locale, options); JPY's minor unit is 0 per ISO 4217, which Intl.NumberFormat respects automatically.