# Migrate from react-intl

Replace react-intl (FormatJS) with @comvi/react: the provider, composing the loader, which ICU syntax carries over and which moves into code, and <FormattedMessage> to <T>.

Canonical: https://comvi.io/docs/i18n/guides/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

| 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](/docs/i18n/icu-reference#what-icu-does-not-implement).

## Migration steps

1. **Install the packages**
**npm**

     ```bash
     npm install @comvi/react @comvi/plugin-fetch-loader
     ```

**pnpm**

     ```bash
     pnpm add @comvi/react @comvi/plugin-fetch-loader
     ```

**yarn**

     ```bash
     yarn add @comvi/react @comvi/plugin-fetch-loader
     ```

**bun**

     ```bash
     bun add @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)`

   ```tsx
   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>,
   );
   ```

   `src/i18n.ts`

   ```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`

   ```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" />}>

     </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.

3. **Replace `useIntl()` with `useI18n()`**

   `src/Greeting.tsx (react-intl)`

   ```tsx
   import { useIntl } from 'react-intl';

   export function Greeting() {
     const intl = useIntl();
     return <h1>{intl.formatMessage({ id: 'greeting' }, { name: 'Alice' })}</h1>;
   }
   ```

   `src/Greeting.tsx`

   ```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)`

   ```tsx
   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`

   ```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`

   ```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](#defaultmessage-has-three-ports).

   > **note**
>
> `<T>` is where tag parsing happens. Plain `t()` returns a string and leaves `<link>` as
> literal text unless you register the tag syntax ambiently with `@comvi/core/tags`.
> Migrate `FormattedMessage` to `<T>`, not to `t()`. See
> [`<T>` vs plain `t()`](/docs/i18n/tag-interpolation#t-versus-plain-t).

5. **Move number and date formatting out of the message**

   `src/Receipt.tsx (react-intl)`

   ```tsx
   // "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`

   ```json
   {
     "balance": "Balance: {amount}",
     "published": "Published: {date}"
   }
   ```

   `src/Receipt.tsx`

   ```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`

   ```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](#defaultmessage-has-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](https://app.comvi.io),
   and the CDN serves it to the loader you composed in step 2.

7. **Remove react-intl and FormatJS**

   ```bash
   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.

## `defaultMessage` has three ports

`src/Welcome.tsx`

```tsx

export function Welcome() {
const { t } = useI18n();
return (
    <>

            <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

`@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.

## 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](#extraction-has-no-equivalent)  | `comvi push` uploads, it does not extract     |
| `@formatjs/cli compile`                       | not needed                                          | the CDN serves the catalog                         |

## Read next

- [React](/docs/i18n/react): all of `@comvi/react`: hooks, capability hooks, `<T>`, SSR.
- [Pluralize with ICU](/docs/i18n/pluralization): which compiler you have, and exactly what ICU implements.
- [Type-check translation keys](/docs/i18n/type-safety): generated `TranslationKeys`, so a bad key fails at compile time.
