Migrate from react-intl
Move a React app from react-intl to @comvi/react; both speak ICU MessageFormat, so most
message strings carry over untouched. What changes is the setup, the hook and component
names, and two ICU argument types Comvi does not implement, which move into code and take the
longest.
Key differences
Section titled “Key differences”| Feature | react-intl | @comvi/react |
|---|---|---|
| Provider | <IntlProvider> |
<I18nProvider>, still required |
| Translation hook | useIntl() |
useI18n() |
| Translation function | intl.formatMessage({ id }, values) |
t(key, params) |
| Rich text component | <FormattedMessage> with render-function values |
<T> with a components prop |
| Plurals / select | ICU plural, selectordinal, select |
the same three, but only with compiler: icuCompiler |
| Number / date in ICU | {v, number}, {v, date}, ::currency/USD |
not implemented; format in code with formatNumber / formatCurrency |
| Standalone formatters | <FormattedNumber>, intl.formatNumber() |
formatNumber / formatCurrency / formatDate / formatRelativeTime |
| Default messages | defaultMessage in code |
<T fallback> or <T>’s children; t(key, { fallback }) |
| Translation source | a messages object you pass to <IntlProvider> |
inline messages, or a loader you compose, including the Comvi CDN |
| Locale switch | re-render <IntlProvider> with new locale and messages |
setLocale(locale), which loads the target locale |
| Type safety | a hand-written FormatjsIntl.Message augmentation |
generated TranslationKeys augmentation |
| Extraction | @formatjs/cli extract |
no equivalent; comvi push uploads files you already wrote |
useI18n(), the capability hooks and <T> read React context and throw [@comvi/react] Hooks must be used within an I18nProvider. without a provider;
useLocale() reads a separate context and throws
[@comvi/react] useLocale must be used within an I18nProvider. Nothing about Comvi is global. And
ICU is opt-in and a subset: the default compiler handles {param} and nothing else,
plural / selectordinal / select need compiler: icuCompiler, and the {v, number} and
{v, date} argument types, the ::currency/USD skeleton and the plural offset: form are
not implemented. Each throws E_ICU_SYNTAX in development; production renders the message
with its braces and reports the error. See
What ICU does not implement.
Migration steps
Section titled “Migration steps”-
Install the packages
Terminal window npm install @comvi/react @comvi/plugin-fetch-loaderTerminal window pnpm add @comvi/react @comvi/plugin-fetch-loaderTerminal window yarn add @comvi/react @comvi/plugin-fetch-loaderTerminal window bun add @comvi/react @comvi/plugin-fetch-loaderKeep
react-intlinstalled until Remove react-intl and FormatJS, so the files you have not migrated yet still build.@comvi/reactpeer-depends onreactonly, so keep your existingreact-dom. -
Replace
IntlProviderwith a composed instance plusI18nProvidersrc/main.tsx (react-intl) import { createRoot } from 'react-dom/client';import { IntlProvider } from 'react-intl';import messages from './locales/en.json';import App from './App';createRoot(document.getElementById('root')!).render(<IntlProvider locale="en" messages={messages}><App /></IntlProvider>,);src/i18n.ts import { createI18n, icuCompiler } from '@comvi/react';import { cdnLoader } from '@comvi/plugin-fetch-loader';export const i18n = createI18n({locale: 'en',fallbackLocale: 'en',compiler: icuCompiler,}).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));src/main.tsx import { createRoot } from 'react-dom/client';import { I18nProvider } from '@comvi/react';import { i18n } from './i18n';import App from './App';createRoot(document.getElementById('root')!).render(<I18nProvider i18n={i18n} fallback={<div className="spinner" />}><App /></I18nProvider>,);The i18n instance is built once, and everything beyond plain
{param}interpolation is composed onto it: here the ICU compiler and the CDN loader.cdnLoadercomposes the loader and plugin capabilities itself.compiler: icuCompilercovers catalogs that arrive from a loader as readily as inline ones, with no ordering rule. Skip the option if your messages use nopluralorselect.<I18nProvider>callsinit()on mount and rendersfallbackuntil the catalog has arrived, so raw keys never paint.The CDN endpoint URL is under Settings → Content Deploy in your Comvi project, once Content Deploy is enabled and saved; pass it as
cdnUrl. The URL format is on CDN Deployment. -
Replace
useIntl()withuseI18n()src/Greeting.tsx (react-intl) import { useIntl } from 'react-intl';export function Greeting() {const intl = useIntl();return <h1>{intl.formatMessage({ id: 'greeting' }, { name: 'Alice' })}</h1>;}src/Greeting.tsx import { useI18n } from '@comvi/react';export function Greeting() {const { t } = useI18n();return <h1>{t('greeting', { name: 'Alice' })}</h1>;}react-intl ( useIntl())Comvi ( useI18n())intl.formatMessage({ id }, values)t(key, params)intl.formatMessagewith tag functionsthe <T>component;tRaw()does not parse tagsintl.localelocaleintl.formatNumber(v, opts)formatNumber(v, opts)intl.formatDate(v, opts)formatDate(v, opts)intl.formatTime(v)formatDate(v, { timeStyle: 'short' })intl.formatRelativeTime(v, unit)formatRelativeTime(v, unit)(none) isLoading,isInitializing,dirThe formatters
useI18n()returns are bound to the render-time locale, so they update with it. The same functions exist as free functions on@comvi/core, taking the i18n instance as their first argument, for code outside a component. The loader and plugin members are on their own hooks:reloadMessages,addActiveNamespaceandonLoadErrorcome fromuseI18nLoader(), andonMissingKeyfromuseI18nPlugins(). Each throws a message naming the fix on an instance that never composed that capability.formatList,formatDisplayName,formatDateTimeRangeandformatDurationhave no Comvi wrapper: call the matchingIntlconstructor withlocalefromuseI18n(). -
Replace
<FormattedMessage>with<T>src/LegalNotice.tsx (react-intl) import { FormattedMessage } from 'react-intl';export function LegalNotice() {return (<FormattedMessageid="legal.notice"defaultMessage="Read our <link>terms of service</link>"values={{ link: (chunks) => <a href="/terms">{chunks}</a> }}/>);}src/LegalNotice.tsx // Catalog: "legal.notice": "Read our <link>terms of service</link>"import { T } from '@comvi/react';export function LegalNotice() {return <T i18nKey="legal.notice" components={{ link: <a href="/terms" /> }} />;}src/Callout.tsx // Catalog: "callout": "This is <b>important</b>"import { T } from '@comvi/react';export function Callout() {return (<Ti18nKey="callout"components={{ b: ({ children }) => <b className="hi">{children}</b> }}/>);}componentsmaps each tag to an element, a bare HTML tag name, or a component. An element is cloned and given the tag’s children; a component renders with{ children }. The message keeps its<b>…</b>tags, and a react-intl chunk handler ports by taking{ children }where it tookchunks; the body stays. For tags you passed indefaultRichTextElements, give each<T>the handler, or list plain HTML tags intagInterpolation: { basicHtmlTags: ['b', 'i'] }oncreateI18n()so<T>renders them without one.defaultMessagegoes to one of the three ports. -
Move number and date formatting out of the message
src/Receipt.tsx (react-intl) // "balance": "Balance: {amount, number, ::currency/USD}"// "published": "Published: {date, date, long}"import { FormattedMessage } from 'react-intl';export function Receipt() {return (<><p><FormattedMessage id="balance" values={{ amount: 1234.56 }} /></p><p><FormattedMessage id="published" values={{ date: new Date() }} /></p></>);}src/locales/en.json {"balance": "Balance: {amount}","published": "Published: {date}"}src/Receipt.tsx import { useI18n } from '@comvi/react';export function Receipt() {const { t, formatCurrency, formatDate } = useI18n();return (<><p>{t('balance', { amount: formatCurrency(1234.56, 'USD') })}</p><p>{t('published', {date: formatDate(new Date(), { year: 'numeric', month: 'long', day: 'numeric' }),})}</p></>);}{amount, number, ::currency/USD}and{date, date, long}throwE_ICU_SYNTAXin development and render their braces literally in production, so this step cannot be skipped and cannot be automated. Format in code and interpolate the result as a plain{param}. You lose per-locale control of the format inside the string, and gain everyIntloption in code. A locale that needs a different format needs a different message, not a different skeleton. -
Migrate the message files
src/locales/en.json {"greeting": "Hello, {name}!","items": "{count, plural, =0 {No items} one {# item} other {# items}}","status": "{gender, select, male {He} female {She} other {They}} left a review"}Plurals, ordinals and select carry over unchanged, provided the instance names the ICU compiler. What does not carry over:
react-intl message in Comvi {v, number}/{v, number, ::…}format in code, interpolate as {v}{d, date}/{d, time}format in code, interpolate as {d}{c, plural, offset:1 …}not implemented; split the message, or pass an adjusted count defaultMessagein codeput the string in the catalog, or see the three ports formatjs compile --astoutputnot a catalog; upload the source strings Then upload each locale’s JSON in your project on app.comvi.io, and the CDN serves it to the loader you composed in step 2.
-
Remove react-intl and FormatJS
Terminal window grep -r "react-intl\|formatjs" src/ --include="*.ts" --include="*.tsx"Then drop
react-intl,@formatjs/cli, the@formatjs/intl-*polyfills andbabel-plugin-formatjs, and delete any remainingdefaultMessageprops.
defaultMessage has three ports
Section titled “defaultMessage has three ports”import { T, useI18n } from '@comvi/react';
export function Welcome() { const { t } = useI18n(); return ( <> {/* interpolated with the same params as the real message */} <T i18nKey="welcome" params={{ name: 'Alice' }} fallback="Hi {name}" /> {/* children render only when the key is missing and no fallback was given */} <T i18nKey="tagline">Nothing to see yet</T> <p>{t('welcome', { name: 'Alice', fallback: 'Hi {name}' })}</p> </> );}| react-intl | Comvi | Interpolated? |
|---|---|---|
<FormattedMessage id defaultMessage="Hi {name}" /> |
<T i18nKey fallback="Hi {name}" /> |
yes |
| a JSX default with markup | <T i18nKey>…</T> with children |
no, rendered as-is |
intl.formatMessage({ id, defaultMessage }) |
t(key, { fallback: 'Hi {name}' }) |
yes |
The catalog is where a string belongs, and moving defaultMessage values into it is the right
end state. fallback outranks children, and both outrank the key itself. A per-call fallback
also outranks any onMissingKey handler. Treat these as a migration aid and a last-resort
default rather than a second catalog. Translators never see a string that exists only in a
fallback.
Extraction has no equivalent
Section titled “Extraction has no equivalent”@formatjs/cli extract scans your source for defaultMessage and writes a catalog from it.
Comvi has no such command, and comvi push is not one: it uploads the translation files that
already exist on disk. Nothing reads your JSX.
- Run
formatjs extract 'src/**/*.tsx' --format simple --out-file en.jsonone last time, before you deletedefaultMessage.--format simplewrites{ "id": "message" }; the default output wraps each message in{ "defaultMessage": … }and is not a catalog. - From then on, add keys where you add the code, in the catalog JSON or directly in the Comvi platform.
- Push and pull the files with
comvi push/comvi pull, and keep the type checks current withcomvi typegen --checkin CI, which fails when the generated declarations are stale.
@comvi/vite-plugin closes the loop from the other direction: it types every t() call
against the catalog, so a key that exists in code and not in the catalog is a compile error
rather than a missing extraction.
API mapping reference
Section titled “API mapping reference”| react-intl | Comvi | Notes |
|---|---|---|
<IntlProvider locale messages> |
createI18n(…).use(…) + <I18nProvider i18n> |
the provider is required |
useIntl() |
useI18n() |
no loader or plugin members on it |
intl.formatMessage({ id }, vals) |
t(key, params) |
returns a string; tags stay literal |
<IntlProvider locale={next}> |
setLocale(next) from useI18n() |
loads the target locale’s catalog |
<FormattedMessage id values={{fn}} /> |
<T i18nKey components={{ tag: <el /> }} /> |
element, tag name, or ({ children }) => element |
<FormattedNumber value={n} /> |
formatNumber(n) from useI18n() |
not an ICU argument type in Comvi |
<FormattedDate value={d} /> |
formatDate(d, opts) from useI18n() |
not an ICU argument type in Comvi |
<FormattedRelativeTime /> |
formatRelativeTime(v, unit) from useI18n() |
|
defineMessages({...}) |
not needed | keys are strings; types come from typegen |
@formatjs/cli extract |
nothing; see Extraction | comvi push uploads, it does not extract |
@formatjs/cli compile |
not needed | the CDN serves the catalog |
Read next
Section titled “Read next”- React: all of
@comvi/react: hooks, capability hooks,<T>, SSR. - Pluralize with ICU: which compiler you have, and exactly what ICU implements.
- Type-check translation keys: generated
TranslationKeys, so a bad key fails at compile time.