Skip to content
Comvi

Translate text with t()

View .md

Call t() with a key and get the text for the current locale. All it takes is the i18n instance from the Quick start and a binding, or bare @comvi/core.

src/Greeting.tsx
import { useI18n } from '@comvi/react';
export function Greeting() {
const { t } = useI18n();
return <h1>{t('welcome', { name: 'Alice' })}</h1>;
}

useI18n() returns the t() bound to the provider’s instance. Outside a component, call i18n.t() after await i18n.init(). t() always returns a string; a translation with tags renders through <T> on Insert values and markup.

src/locales/en.json
{
"user": {
"profile": { "title": "My Profile" }
},
"errors.validation.required": "This field is required"
}
src/Profile.tsx
import { useI18n } from '@comvi/react';
export function Profile() {
const { t } = useI18n();
return (
<>
<h1>{t('user.profile.title')}</h1>
<p>{t('errors.validation.required')}</p>
</>
);
}

Keys use dot notation. A nested catalog and a flat one are the same catalog: the instance flattens nested JSON when it ingests it, so both keys resolve the same way.

To read a key from another namespace, pass ns in the params, never as a prefix in the key string. t('dashboard:welcome') looks up the literal key dashboard:welcome.

src/Dashboard.tsx
import { useI18n } from '@comvi/react';
export function Dashboard() {
const { t } = useI18n('dashboard');
return (
<>
<h1>{t('page.title')}</h1>
<button>{t('buttons.save', { ns: 'common' })}</button>
</>
);
}

useI18n('dashboard') scopes every t() in the component to that namespace, and an explicit ns still wins for one call. Scoping does not load the namespace: load it at init or with addActiveNamespace(), on Split translations into namespaces.

src/Options.tsx
import { useI18n } from '@comvi/react';
export function Options() {
const { t } = useI18n();
const isDefault = t('welcome', { name: 'Alice', markers: false }) === 'Welcome, Alice!';
return (
<p data-default={isDefault}>
{t('welcome', {
name: 'Alice',
ns: 'dashboard',
locale: 'de',
fallback: 'Welcome, {name}!',
})}
</p>
);
}

The params object mixes interpolation values with four call options:

Option Type Effect
ns string Namespace for this call. Defaults to defaultNs.
locale string Renders this locale instead of the current one.
fallback string Returned, with interpolation, when the key is missing or empty in every locale of the chain.
markers boolean false turns the in-context editor’s invisible markers off for this call.

Use locale for mixed-language content such as a language name shown in its own language. Use markers: false when the editor’s invisible markers cause a problem: a comparison or sort that sees extra characters, a width-sensitive layout, a canvas or a library that counts characters. Nothing else reads the option, and that one string is then not editable in context.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { icuCompiler } from '@comvi/core/icu';
export const i18n = createI18n({
locale: 'de',
compiler: icuCompiler,
defaultParams: { formality: 'formal' },
messages: {
de: { review: '{formality, select, formal {Ihre Bewertung} other {Deine Bewertung}}' },
},
});
src/review.ts
import { i18n } from './i18n';
await i18n.init();
i18n.t('review'); // formality: "formal"
i18n.t('review', { formality: 'informal' }); // per-call override
i18n.setDefaultParams({ formality: 'informal' });

defaultParams applies to every t() call on the instance, and a per-call value wins key by key. setDefaultParams() replaces the whole object rather than merging, and a key supplied at construction must stay present with a compatible value. Defaults hold interpolation data only: ns, locale, fallback, markers and tagInterpolation are rejected there, and so are null and undefined. The select in the catalog is why the instance names compiler: icuCompiler, on Pluralize with ICU.

src/i18n.ts
import { createI18n } from '@comvi/core';
export const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
onMissingKey: ({ key, locale, namespace }) => {
console.warn(`Missing translation: ${key} [${locale}/${namespace}]`);
// Return a string to render it, or nothing to render the key.
},
});

The onMissingKey option runs after the fallback chain has been tried. Return a string to render it, or nothing to render the key itself. A fallback passed to the call outranks the option, so the option never sees a call that carries params.fallback; a report that counts misses belongs in a runtime callback instead. Runtime registration is part of the plugin capability: .use(plugins()) on the instance, or useI18nPlugins() in a component.

src/MissingKeyReporter.tsx
import { useEffect } from 'react';
import { useI18nPlugins } from '@comvi/react';
declare function reportToSentry(info: Record<string, string>): void;
export function MissingKeyReporter() {
const { onMissingKey } = useI18nPlugins();
useEffect(
() => onMissingKey((key, locale, ns) => reportToSentry({ key, locale, ns })),
[onMissingKey],
);
return null;
}

The runtime callback takes three positional arguments and returns an unsubscribe function. Several can be registered, and they all run on every miss; the first non-undefined result wins. Keep the callbacks for diagnostics and params.fallback for the text a user sees.

t() picks a catalog before it reads anything. The locale is params.locale or the current one; the namespace is params.ns or defaultNs. Each (locale, namespace) pair is one flat object in the cache, flattened once where the catalog entered the instance, so a lookup is a single property read and key depth costs nothing at render. That flat form is also what getMessages(locale, namespace) returns.

When the key is absent from that catalog, or its value is an empty string, the lookup walks the fallback chain in the same namespace, in array order, and the first non-empty value wins. A message found in a fallback locale is compiled with that locale’s plural rules, not the current one’s, so an English fallback for a Ukrainian key pluralizes as English. When no locale in the chain has text for the key, the missing-key path runs, in this order:

  1. With strict: 'dev', log [@comvi/core] Translation not found: "key" in a development build; a production build logs nothing here.
  2. Emit the missingKey event.
  3. Run every runtime callback registered with onMissingKey(), keeping the first non-undefined result. These run even when params.fallback is set.
  4. If params.fallback is set, return it interpolated. A runtime result is discarded.
  5. Otherwise, if no runtime callback returned a value, run the onMissingKey option.
  6. Return the first value produced, or the key itself.

Interpolation is a template step on whichever message won. A message is parsed once into tokens and cached by its text, so the first call pays for the parse and every later call pays for a lookup and a concatenation; a message with no {, <, & or ' in it is returned as is. {name} takes the value under that key. Strings, numbers and booleans are concatenated; a node from @comvi/core/rich-text or a framework element renders as an element, and anything else goes through String(). fallback is a template too: {name} in it is filled from the same params, and a broken ICU argument in it fails at the call in development, as one in the catalog does at ingestion. What a missing parameter renders is on Insert values and markup.

All of that happens synchronously inside the call. t() reads the catalog that is in the cache at that moment and never waits for a load, so a key whose namespace has not arrived yet goes down the missing-key path and renders as itself. That is why a provider fallback or an isLoading check belongs around the first paint, where a key flashing as its own name would be visible.

What makes the text update later is the binding, not t(). A component that calls useI18n() subscribes to the instance’s revision events: localeChanged, namespaceLoaded, loadingStateChanged, initialized, messagesCleared, defaultNamespaceChanged and configChanged. Each of them re-renders that component and no other, so a locale switch or a catalog that lands later refreshes every t() on screen without a store of your own, and a component that never read the instance is untouched. On a bare @comvi/core instance you subscribe yourself with i18n.on().