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 to see the switcher, plurals, <T> with a link inside the text and catalogs loaded lazily per locale.
Install
Section titled “Install”npm install @comvi/reactpnpm add @comvi/reactyarn add @comvi/reactbun add @comvi/react@comvi/react peer-depends on react (^18 or ^19) and brings @comvi/core, so one install is
enough.
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.
Use in components
Section titled “Use in components”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 }.
Markup inside a translation
Section titled “Markup inside a translation”{ "legal.tos": "Read our <link>Terms of Service</link>", "welcome.message": "Welcome back, <bold>{name}</bold>!"}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.
Load from Comvi
Section titled “Load from Comvi”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.
First paint
Section titled “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
Section titled “Server-side rendering”@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.
Switch without blocking
Section titled “Switch without blocking”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.
How it works
Section titled “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
Section titled “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 |
More messages and their fixes are on Troubleshooting.
Read next
Section titled “Read next”- Connect to Comvi: the CDN URL, and a wording change that ships without a redeploy.
- Load translations on demand:
loader(map), namespaces anduseI18nLoader(). - React API reference: every export, hook and provider prop.