Skip to content
Comvi

Switch the locale

View .md

Switch the locale at runtime and every component that reads the instance re-renders. You need the instance from the Quick start with a second locale in its catalog.

src/LanguageSwitcher.tsx
import { useI18n } from '@comvi/react';
const languages = ['en', 'de', 'fr', 'ja'];
export function LanguageSwitcher() {
const { locale, setLocale, isLoading } = useI18n();
return (
<select value={locale} onChange={(e) => setLocale(e.currentTarget.value)} disabled={isLoading}>
{languages.map((lang) => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
);
}

setLocale() in a binding calls the instance’s setLocale(), which returns a promise; the locale setter is the same call with the promise dropped. Both emit localeChanged:

src/switch.ts
import { i18n } from './i18n';
i18n.locale = 'fr'; // fire and forget
await i18n.setLocale('fr'); // resolves once the locale is applied

Disabling the selector while isLoading is true keeps a second switch from starting mid-load. In Nuxt the URL carries the locale, so the switcher navigates to useSwitchLocalePath()(code) instead of calling setLocale(); the route middleware applies the locale and writes the cookie. setLocale() alone switches in place and is enough only under localePrefix: 'never'. On an instance with no loader there is nothing to fetch: the locale is applied at once from the catalogs already in the cache, and await returns immediately.

src/i18n.ts
import { createI18n } from '@comvi/core';
export const i18n = createI18n({
locale: 'de',
fallbackLocale: 'en',
});
i18n.setFallbackLocale(['de-AT', 'de', 'en']); // tried in order

A key missing from the current locale is looked up along the chain before missingKey fires. The order is fixed: the current locale’s catalog for the namespace first, then each entry of fallbackLocale in array order, always in the same namespace. Put your most complete locale last so every key has a terminal answer. What happens after the chain is exhausted is on Translate text with t().

src/i18n.ts
import { createI18n } from '@comvi/core';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import { localeDetector } from '@comvi/plugin-locale-detector';
export const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
})
.use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }))
.use(
localeDetector({
supportedLocales: ['en', 'de', 'fr', 'ja'],
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
}),
);

Without the plugin, a reload starts from the configured locale. With it, the first visit reads navigator.languages, a switch writes the choice to localStorage, and the next visit starts from the saved value. List the locales you ship in supportedLocales, so a browser language outside the list falls back to locale instead of becoming the active one; without the list, development warns. Every source and cache is on Locale detector. Putting the locale in the URL is a framework concern: Next.js routing and the Nuxt module each ship it.

src/HtmlAttrs.tsx
import { useEffect } from 'react';
import { useI18n } from '@comvi/react';
export function HtmlAttrs() {
const { dir, locale } = useI18n();
useEffect(() => {
document.documentElement.dir = dir;
document.documentElement.lang = locale;
}, [dir, locale]);
return null;
}

The bindings re-render the component tree on their own. No binding writes lang or dir on <html> for you, apart from the Nuxt module, so a small HtmlAttrs component that writes both in an effect is where that happens. For side effects outside the tree, such as analytics or a refetch, subscribe on the instance:

src/locale-events.ts
import { getTextDirection } from '@comvi/core';
import { i18n } from './i18n';
declare const analytics: { track(event: string, payload: Record<string, string>): void };
const unsubscribe = i18n.on('localeChanged', ({ from, to }) => {
document.documentElement.dir = getTextDirection(to);
document.documentElement.lang = to;
analytics.track('language_changed', { from, to });
});
unsubscribe();

getTextDirection() reads Intl.Locale where the runtime has it, so it covers script subtags such as ku-Arab, region tags such as he-IL and every CLDR right-to-left locale that a hand-written list would miss. With CSS logical properties such as margin-inline-start, most layouts need no RTL-specific rules.

src/RefreshButton.tsx
import { useI18nLoader } from '@comvi/react';
export function RefreshButton() {
const { reloadMessages } = useI18nLoader();
return <button onClick={() => reloadMessages()}>Refresh translations</button>;
}

reloadMessages() drops the cache entries it targets and fetches them again through the loader: with no arguments the current locale and its fallback chain, with 'de' every active namespace for German, with 'de', 'dashboard' one namespace. It lives on the loader capability, useI18nLoader() in a component or the composed instance outside one; a component reaching for it on an instance with no loader throws. Calling it twice fetches twice, so use it for invalidation rather than as a preload.

setLocale() from useI18n() is the instance’s setLocale(), and i18n.locale = 'fr' is the same call with its promise dropped: a failed load then surfaces as a loadError event with the namespace locale-change instead of a rejection, so prefer await setLocale() where the failure matters.

What the switch waits for depends on what is composed. On an instance with a loader, .use(loader(map)) or .use(cdnLoader({ cdnUrl })), setLocale('de') runs in a fixed order. It marks the instance as loading, so isLoading turns true and loadingStateChanged fires. It fetches de for every active namespace that is not already in the cache, in parallel, and waits for all of them. Only then does it write the locale and emit localeChanged, so no component ever renders the new locale against an old catalog and nothing flashes untranslated. Finally it clears the loading state. When every active namespace of the target locale is already cached, nothing loads, isLoading never turns true, and the locale is written synchronously, inside the call: each loaded (locale, namespace) pair stays cached until clearMessages(), reloadMessages() or destroy(), so a locale switched back to applies at once. On an instance with no loader the whole sequence collapses to the last step: the locale is written and localeChanged fires synchronously.

A switch that a newer switch overtakes applies nothing and reports nothing. Each call takes a change id, and when it resumes after its fetch it checks that it is still the latest; if the user clicked fr and then ja while fr was loading, the fr result lands in the cache but the locale goes straight to ja. Reverting to the current locale while another switch is in flight cancels that switch the same way. Only the last requested locale is ever observable, so the selector can stay enabled.

Every binding listens to the same revision events, localeChanged and namespaceLoaded among them, and turns them into its own reactivity. In React the provider holds one external-store subscription, so every component that called useI18n() re-renders together and a component that never did is untouched. In Vue the locale, the loading flags and the cache revision are shallow refs, so a template re-renders only where it read t, locale or dir. Svelte exposes them as stores, which is why the code reads $locale and $isLoading. Solid exposes signals, locale() and isLoading(), and tracks them like any other signal. In each case the bound formatters and dir derive from the same locale value, so they follow in the same render.

setFallbackLocale() emits configChanged, which is a revision event too, so the subscribed components re-render with the new chain. A hit found in a fallback locale is compiled with the rules of the locale it was found in, so a plural served from the English catalog uses English categories.

Nothing on the instance persists the choice; a reload starts from the configured locale. The locale detector is a plugin that runs during init(), before any namespace is fetched, so the detected locale is the one the first load fetches. It reads the sources in order and stops at the first one that returns a value, writes that detection to each target in caches once init() succeeds, and afterwards listens for localeChanged and writes each new locale the same way, so the next visit starts from the user’s choice. Every source skips on the server, where window and navigator do not exist, so the instance keeps its configured locale there.