# React

Add Comvi i18n to a React app: install @comvi/react, wrap the tree in <I18nProvider>, translate with useI18n() and <T>, and load catalogs from the CDN.

Canonical: https://comvi.io/docs/i18n/react

With a React 18 or 19 app in front of you, `@comvi/react` adds translated text in three pieces:
one provider at the root, `useI18n()` in the components that show text, and `<T>` where a
translation carries markup.

Open the [React example in StackBlitz](https://stackblitz.com/github/comvi-io/comvi-js/tree/main/examples/react) to see the switcher, plurals, `<T>` with a link inside the text and catalogs loaded lazily per locale.

## Install
**npm**

```bash
npm install @comvi/react
```

**pnpm**

```bash
pnpm add @comvi/react
```

**yarn**

```bash
yarn add @comvi/react
```

**bun**

```bash
bun add @comvi/react
```

`@comvi/react` peer-depends on `react` (^18 or ^19) and brings `@comvi/core`, so one install is
enough.

## Setup

`src/main.tsx`

```tsx

const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
    en: { greeting: 'Hello, {name}!' },
    uk: { greeting: 'Привіт, {name}!' },
},
});

createRoot(document.getElementById('root')!).render(

</I18nProvider>,
);
```

`createI18n()` returns the i18n instance, with the inline catalog already in its cache.
`<I18nProvider>` puts the instance in context and calls `init()` from an effect on mount, so there
is no `init()` to write. With an inline catalog the first render already has its text; the
options are on the [Quick start](/docs/i18n/quick-start#create-the-i18n-instance).

## Use in components

`src/App.tsx`

```tsx

export default function App() {
const { t, locale, setLocale } = useI18n();

return (
    <>
      <h1>{t('greeting', { name: 'Alice' })}</h1>
      <select value={locale} onChange={(e) => setLocale(e.target.value)}>
        <option value="en">English</option>
        <option value="uk">Українська</option>
      </select>
    </>
);
}
```

`t()` fills `{name}` from its second argument and returns a string. `setLocale()` resolves once
the new locale's catalog is loaded, and every component that called `useI18n()` re-renders with
the new text; with an inline catalog there is nothing to load, so the switch is one render.
`useI18n('admin')` binds a namespace, so `t('dashboard')` reads `admin:dashboard`. A component
that only needs the locale calls `useLocale()` instead, which subscribes to the locale alone and
skips the re-renders a namespace load or a loading-state change would cause; `useIsLoading()` is
the matching selector for `{ isLoading, isInitializing }`.

### Markup inside a translation

`src/locales/en.json`

```json
{
"legal.tos": "Read our <link>Terms of Service</link>",
"welcome.message": "Welcome back, <bold>{name}</bold>!"
}
```

`src/LegalNotice.tsx`

```tsx

export function LegalNotice() {
return (
    <>
      <T i18nKey="legal.tos" components={{ link: <a href="/terms" className="underline" /> }} />
      <T i18nKey="welcome.message" components={{ bold: <strong /> }} name="Alice" />
      <T i18nKey="optional.key">Fallback text</T>
    </>
);
}
```

`<T>` maps each tag a translator wrote to a React element, a function component or an HTML tag
name, and renders real elements, never an HTML string. Every prop other than `i18nKey`, `params`,
`ns`, `locale`, `fallback`, `markers`, `components` and `children` is an interpolation
param, so `name="Alice"` fills `{name}`. Children render when the key is missing. Plain `t()` does
not parse tags. See [Tag interpolation](/docs/i18n/tag-interpolation).

## Load from Comvi

`src/main.tsx`

```tsx

const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
}).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));

createRoot(document.getElementById('root')!).render(
<I18nProvider i18n={i18n} fallback={<div className="spinner" />}>

</I18nProvider>,
);
```

Install `@comvi/plugin-fetch-loader` and compose `.use(cdnLoader())` inside the `createI18n`
expression. The order matters: `.use()` attaches the loader capability at once, but the CDN plugin
behind it runs inside `init()`, and the provider calls `init()` for you on mount. The plugin
fetches `<cdnUrl>/<locale>.json` for the default namespace, for the active locale and for each
locale in `fallbackLocale`, once per `locale:namespace`. The rest of the walkthrough is on
[Connect to Comvi](/docs/i18n/connect-comvi).

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](/docs/platform/cdn#url-format).
If your keys use ICU (`{count, plural, …}`), add `compiler: icuCompiler` as on
[Pluralize with ICU](/docs/i18n/pluralization). The platform and the compiler are independent:
either works without the other.
## First paint

`fallback` renders in place of the children until the instance is initialized, so raw keys never
paint while the CDN responds. Pass `false` or `null` for a blank gate. The gate reads
`isInitialized`, not `isInitializing`: `init()` starts from an effect after the first render, so a
gate on "is it initializing" would be open for exactly the frame the raw keys would paint in. Leave
the prop off when a server render already produced real text, otherwise the client swaps that text
for the fallback until initialization finishes.

## Server-side rendering

`@comvi/react` ships no SSR binding of its own; on Next.js use [`@comvi/next`](/docs/i18n/next).
For hand-rolled SSR, build a fresh instance per request, render, and hydrate through
`<I18nProvider>` with `ssrLocale`, `ssrInitialIsLoading` and `ssrInitialIsInitializing` set to
what the server rendered, so the first client render matches the HTML. When the catalog is remote
and the server painted nothing, pass `ssrInitialIsInitializing={true}` with `fallback`: the server
and the hydration render both paint the fallback, and the children replace it once the client
initializes. See [Render on the server](/docs/i18n/ssr).

## Switch without blocking

`src/LocaleSwitcher.tsx`

```tsx

export function LocaleSwitcher() {
const { isPending, setLocale } = useSetLocaleTransition();

return (
    <button onClick={() => setLocale('de')} disabled={isPending}>
      {isPending ? 'Loading…' : 'Deutsch'}
    </button>
);
}
```

`useSetLocaleTransition()` wraps the switch in `useTransition`, so the old UI stays interactive
while the new catalog loads, and `isPending` covers the whole switch, fetch included. The provider
subscribes through `useSyncExternalStore`, so concurrent rendering never sees a torn locale.

## How it works

The instance `createI18n()` returns knows nothing about React. It holds a translation cache keyed
`locale:namespace`, the current locale, the fallback chain and an event emitter. `<I18nProvider>`
is the bridge: it stores the instance in context and subscribes to its events through
`useSyncExternalStore`, with one subscription for the locale (`localeChanged`, `initialized`,
`configChanged`) and one for the loading flags (`loadingStateChanged`, `initialized`). The locale
travels down a context of its own, so `useLocale()` re-renders on nothing else.

`useI18n()` subscribes the calling component to the whole revision set: cache revision, the
initialized flag, default and active namespaces, fallback locales, config revision, locale and the
loading flags. Any change re-renders that component, and no component re-renders for a change it
did not read. Its `t()` is bound to the render-time locale: every call passes the locale React is
tracking rather than reading the mutable `i18n.locale`, so a tree mid-render never mixes two
locales, and under a server-rendered provider the text follows the locale the server chose. Event
callbacks are deferred by a microtask, so a state update never lands inside another component's
render.

`init()` starts from an effect, after the first render. It raises `isInitializing`, runs the
plugins (a locale detector may change the locale here, before anything loads), loads the default
namespace for the current locale and for each locale in `fallbackLocale`, then sets
`isInitialized` and emits `initialized`. A fallback catalog that fails to load is reported and does
not fail `init()`. With an inline catalog the load is a cache hit and the whole sequence completes
within a tick. With a loader, the first render sees an empty cache and `t()` returns raw keys;
`fallback` hides that render.

`setLocale()` from `useI18n()` is the instance's own `setLocale()`. With a loader composed it loads every active
namespace for the new locale first, then assigns the locale and emits `localeChanged`, so the UI
never shows a half-translated frame. When several switches overlap, the latest wins: a superseded
request applies nothing and reports nothing. Without a loader the switch is synchronous.

A missing key is not an error. `t()` looks in the current locale, then in each fallback locale in
order; when nothing matches it emits `missingKey` and returns the key itself, so a raw key on
screen means a missing translation, not a broken provider. `params.fallback` replaces the key for
one call, and `strict: 'dev'` adds a console warning in development.

On a server the instance is a mutable object shared by every in-flight request if it lives at
module scope, and one request's `setLocale()` moves the locale under another's render. Build one
per request, and compose a locale detector on the client only.

## Errors

| what happened | message |
|---|---|
| `useI18n()` outside `<I18nProvider>` | `[@comvi/react] Hooks must be used within an I18nProvider.` |
| `useLocale()` outside `<I18nProvider>` | `[@comvi/react] useLocale must be used within an I18nProvider.` |
| `init()` rejected and the provider has no `onError` | `[@comvi/react] Initialization failed:` followed by the rejection, through `console.error` |
| `useI18nLoader()` on an instance with no loader composed | development: `[@comvi/core] This i18n instance has no loader capability. Compose it: .use(attachLoader) from "@comvi/core/loader", or loader(map).`; a production build throws the short form quoted on [Error codes](/docs/i18n/errors#a-capability-was-never-composed) |

More messages and their fixes are on [Troubleshooting](/docs/i18n/troubleshooting).

## Read next

- [Connect to Comvi](/docs/i18n/connect-comvi): the CDN URL, and a wording change that ships without a redeploy.
- [Load translations on demand](/docs/i18n/loading-translations): `loader(map)`, namespaces and `useI18nLoader()`.
- [React API reference](/docs/i18n/api/react): every export, hook and provider prop.
