Skip to content
Comvi

React

View .md

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 to see the switcher, plurals, <T> with a link inside the text and catalogs loaded lazily per locale.

Terminal window
npm install @comvi/react

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

src/main.tsx
import { createRoot } from 'react-dom/client';
import { createI18n, I18nProvider } from '@comvi/react';
import App from './App';
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: { greeting: 'Hello, {name}!' },
uk: { greeting: 'Привіт, {name}!' },
},
});
createRoot(document.getElementById('root')!).render(
<I18nProvider i18n={i18n}>
<App />
</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.

src/App.tsx
import { useI18n } from '@comvi/react';
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 }.

src/locales/en.json
{
"legal.tos": "Read our <link>Terms of Service</link>",
"welcome.message": "Welcome back, <bold>{name}</bold>!"
}
src/LegalNotice.tsx
import { T } from '@comvi/react';
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.

src/main.tsx
import { createRoot } from 'react-dom/client';
import { createI18n, I18nProvider } from '@comvi/react';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import App from './App';
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" />}>
<App />
</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.

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.

If your keys use ICU ({count, plural, …}), add compiler: icuCompiler as on Pluralize with ICU. The platform and the compiler are independent: either works without the other.

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.

@comvi/react ships no SSR binding of its own; on Next.js use @comvi/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.

src/LocaleSwitcher.tsx
import { useSetLocaleTransition } from '@comvi/react';
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.

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.

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

More messages and their fixes are on Troubleshooting.