React-Intl (FormatJS) Internationalization Patterns
Quick Guide: FormattedMessage renders translated text in JSX, useIntl returns strings for
attributes and programmatic use, and defineMessages produces descriptors the FormatJS CLI can
extract. IntlProvider supplies the context, and its onError is what separates a missing
translation from a real failure. Every plural and select needs an other branch. Version
boundary: v7.x runs on React 16.6 through 19; v8 and later require React 19.
Detailed Resources:
- examples/core.md — provider setup, FormattedMessage, useIntl, defineMessages,
createIntl, locale switching, types, lazy loading
- examples/formatting.md — date, time, number, currency, relative time, list and display-name formatting
- examples/pluralization.md — plural, ordinal, select, nested patterns, per-language categories, ICU escaping
- reference.md — decision trees, ICU syntax tables, API tables, anti-pattern code, checklists
Which path applies
- Text rendered inside JSX —
FormattedMessage, including rich text with tag values. Follow
examples/core.md.
- A string is needed — an attribute, a document title, a value passed to a third-party
component, or a comparison —
useIntl().formatMessage(). Also in
examples/core.md.
- Outside a React tree — a server render or a plain module —
createIntl from
@formatjs/intl, optionally re-supplied through RawIntlProvider. In
examples/core.md.
<critical_requirements>
Before writing react-intl code
Wrap the tree in IntlProvider with locale, messages and defaultLocale. Every
FormattedMessage and useIntl call reads that context, and defaultLocale is what a missing
translation falls back to instead of surfacing the raw ID.
Give every plural and select an other branch. ICU requires it, and a message without one
throws when formatted rather than when authored.
Match the major version to the React version in the project. v7.x covers React 16.6 through 19;
v8 and later dropped everything before React 19.
</critical_requirements>
Auto-detection: react-intl, FormatJS, FormattedMessage, useIntl, IntlProvider, RawIntlProvider,
defineMessages, defineMessage, createIntl, formatMessage, FormattedDate, FormattedNumber,
FormattedRelativeTime, ICU message format
Applies to:
- Rendering messages with ICU interpolation, pluralization, select and rich text
- Formatting dates, numbers, currency, relative time, lists and display names per locale
- Structuring messages as descriptors so the CLI can extract and compile them
- Typing message IDs so a typo fails at compile time
- Loading a locale's messages on demand rather than bundling all of them
Handled elsewhere:
- Where the locale value comes from and how it is persisted — this skill consumes a locale and
settles nothing about detection, routing or storage
- A framework's own built-in i18n — a framework that resolves locale and messages per request will
do that better than a client-side provider, and this skill does not compete with it
- Translation vendor workflow — the CLI produces and consumes JSON, and what happens to it in
between is not this skill's concern
- Rendering and state — components receive messages through context and are otherwise ordinary
<philosophy>
ICU Message Format is the point: it is what translation vendors already speak, so a message written
in it moves through a professional workflow without a conversion step. Everything else follows.
Formatting is delegated to the browser's own Intl APIs rather than reimplemented, which is why
locale-specific behaviour is correct for locales nobody tested. And the API is deliberately doubled —
a component for JSX and a hook for strings — because a ReactNode cannot be put in an attribute.
</philosophy>
<patterns>
Core patterns
Pattern 1: IntlProvider setup
onError is where a missing translation is separated from a real failure, and
defaultRichTextElements gives <b>, <i> and <br> one definition for the whole app.
<IntlProvider
locale={locale}
defaultLocale={DEFAULT_LOCALE}
messages={messages}
defaultRichTextElements={DEFAULT_RICH_TEXT_ELEMENTS}
onError={(err) => {
if (err.code === "MISSING_TRANSLATION") return;
throw err;
}}
>
{children}
</IntlProvider>
Full code: examples/core.md
Pattern 2: FormattedMessage
For text rendered directly in JSX, including messages carrying ICU syntax.
<FormattedMessage
id="greeting.unread"
defaultMessage="{count, plural, =0 {No messages} one {# message} other {# messages}}"
values={{ count: unreadCount }}
/>
It returns a ReactNode, so an attribute — placeholder, aria-label, title — needs Pattern 3
instead.
Full code: examples/core.md
Pattern 3: useIntl
For any context that needs a string: attributes, document titles, third-party props, or a value the
code then compares.
const intl = useIntl();
const placeholder = intl.formatMessage({
id: "search.placeholder",
defaultMessage: "Search products...",
});
Full code: examples/core.md
Pattern 4: defineMessages
Descriptors the CLI can find statically. description is the only channel a translator has for
context.
export const productMessages = defineMessages({
reviewCount: {
id: "product.reviewCount",
defaultMessage:
"{count, plural, =0 {No reviews} one {# review} other {# reviews}}",
description: "Number of product reviews with pluralization",
},
});
Spread a descriptor into FormattedMessage, or pass it to intl.formatMessage.
Full code: examples/core.md
Pattern 5: Rich text
Tags in the message map to values, so the sentence stays in one translation unit and the translator
can reorder the tags to fit the target grammar.
<FormattedMessage
id="terms.notice"
defaultMessage="You agree to our <terms>Terms</terms> and <privacy>Privacy Policy</privacy>."
values={{
terms: (chunks) => <a href="/terms">{chunks}</a>,
privacy: (chunks) => <a href="/privacy">{chunks}</a>,
}}
/>
Full code: examples/core.md
Pattern 6: Formatting
A component per value kind, each with an imperative twin on useIntl for string contexts.
<FormattedDate value={date} year="numeric" month="long" day="numeric" />
<FormattedNumber value={amount} style="currency" currency={currency} />
<FormattedList type="conjunction" value={names} />
Full code: examples/formatting.md
Pattern 7: Type-safe message IDs
Augment the FormatjsIntl.Message interface and a wrong ID becomes a compile error.
declare global {
namespace FormatjsIntl {
interface Message {
ids: keyof typeof messages;
}
}
}
Add "esnext.intl" to compilerOptions.lib.
Full code: examples/core.md
Pattern 8: Extraction workflow
Extract descriptors, send the JSON out for translation, compile what comes back.
formatjs extract 'src/**/*.{ts,tsx}' --out-file lang/en.json
formatjs compile lang/en.json --out-file compiled/en.json --ast
Compiling to AST skips parsing at runtime — 30-50% off first render for a large catalog.
Pattern 9: ICU pluralization
Plural category counts differ by language: English has one/other, Russian adds few and many,
Arabic adds zero and two.
{count, plural, =0 {No items} one {# item} other {# items}}
{position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}
{gender, select, male {He} female {She} other {They}} liked your post.
Full code: examples/pluralization.md
</patterns>
<performance>
Performance
Compile to AST at build time — Pattern 8's formatjs compile --ast step, which is where the
largest single win is.
Load one locale at a time with dynamic imports, and cache what comes back so a switch back is
free. Implementation in examples/core.md.
Define messages outside the component. An object literal passed inline to FormattedMessage is
a new reference every render, which defeats memoization.
createIntl + createIntlCache + RawIntlProvider puts the intl object under explicit control
when you want to memoize it yourself.
</performance>
<red_flags>
Red flags
Breaks at runtime:
- A
plural or select with no other branch — ICU requires it and formatting throws
- Any
useIntl or FormattedMessage outside IntlProvider — there is no context to read
FormattedMessage in an attribute — it is a ReactNode, so placeholder, aria-label and
title receive an object
injectIntl — removed in v10; useIntl replaces it
Surprising behaviour:
formatMessage returns string normally, but string | ReactNode[] as soon as a value is a rich
text tag function
- Rich text tag functions receive
chunks as an array, not a single element
formatNumber with style: "percent" expects a fraction — 0.25 renders as 25%
formatRelativeTime is relative to now: negative is past, positive is future
- ICU escaping runs on single quotes —
' escapes the next special character and '' produces a
literal apostrophe, so an unescaped apostrophe in an English message can swallow the rest of it
- Without
defaultLocale, a missing translation renders the raw message ID
- Without
onError, every missing translation writes to the console
- Concatenating two translated strings assumes English word order, which most languages do not share
{count} on its own never pluralizes, and a plural branch without # renders no number at all
onWarn on IntlProvider is what quiets defaultRichTextElements warnings when messages are not
pre-compiled
Anti-patterns with the code that fixes them: reference.md.
</red_flags>