Skip to content
Comvi

Migrate from react-intl

View .md

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.

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.

  1. Install the packages

    Terminal window
    npm install @comvi/react @comvi/plugin-fetch-loader

    Keep react-intl installed until Remove react-intl and FormatJS, so the files you have not migrated yet still build. @comvi/react peer-depends on react only, so keep your existing react-dom.

  2. Replace IntlProvider with a composed instance plus I18nProvider

    src/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. cdnLoader composes the loader and plugin capabilities itself. compiler: icuCompiler covers catalogs that arrive from a loader as readily as inline ones, with no ordering rule. Skip the option if your messages use no plural or select. <I18nProvider> calls init() on mount and renders fallback until 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.

  3. Replace useIntl() with useI18n()

    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.formatMessage with tag functions the <T> component; tRaw() does not parse tags
    intl.locale locale
    intl.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, dir

    The 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, addActiveNamespace and onLoadError come from useI18nLoader(), and onMissingKey from useI18nPlugins(). Each throws a message naming the fix on an instance that never composed that capability. formatList, formatDisplayName, formatDateTimeRange and formatDuration have no Comvi wrapper: call the matching Intl constructor with locale from useI18n().

  4. Replace <FormattedMessage> with <T>

    src/LegalNotice.tsx (react-intl)
    import { FormattedMessage } from 'react-intl';
    export function LegalNotice() {
    return (
    <FormattedMessage
    id="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 (
    <T
    i18nKey="callout"
    components={{ b: ({ children }) => <b className="hi">{children}</b> }}
    />
    );
    }

    components maps 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 took chunks; the body stays. For tags you passed in defaultRichTextElements, give each <T> the handler, or list plain HTML tags in tagInterpolation: { basicHtmlTags: ['b', 'i'] } on createI18n() so <T> renders them without one. defaultMessage goes to one of the three ports.

  5. 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} throw E_ICU_SYNTAX in 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 every Intl option in code. A locale that needs a different format needs a different message, not a different skeleton.

  6. 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
    defaultMessage in code put the string in the catalog, or see the three ports
    formatjs compile --ast output not 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.

  7. 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 and babel-plugin-formatjs, and delete any remaining defaultMessage props.

src/Welcome.tsx
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.

@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.json one last time, before you delete defaultMessage. --format simple writes { "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 with comvi typegen --check in 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.

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