Switch the locale
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.
Switch the locale
Section titled “Switch the locale”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> );}<script setup lang="ts">import { useI18n } from '@comvi/vue';
const languages = ['en', 'de', 'fr', 'ja'];const { locale, setLocale, isLoading } = useI18n();</script>
<template> <select :value="locale" :disabled="isLoading" @change="setLocale(($event.target as HTMLSelectElement).value)"> <option v-for="lang in languages" :key="lang" :value="lang">{{ lang }}</option> </select></template>import { defineNuxtConfig } from 'nuxt/config';
export default defineNuxtConfig({ modules: ['@comvi/nuxt'], comvi: { defaultLocale: 'en', locales: [{ code: 'en', name: 'English' }, { code: 'de', name: 'Deutsch' }] },});<script setup lang="ts">const { locale, locales } = useI18n();const switchLocalePath = useSwitchLocalePath();</script>
<template> <nav> <NuxtLink v-for="lang in locales" :key="lang" :to="switchLocalePath(lang)" :aria-current="lang === locale ? 'page' : undefined">{{ lang }}</NuxtLink> </nav></template><script lang="ts"> import { useI18n } from '@comvi/svelte';
const languages = ['en', 'de', 'fr', 'ja']; const { locale, setLocale, isLoading } = useI18n();</script>
<select value={$locale} disabled={$isLoading} onchange={(e) => setLocale(e.currentTarget.value)}> {#each languages as lang (lang)} <option value={lang}>{lang}</option> {/each}</select>import { For } from 'solid-js';import { useI18n } from '@comvi/solid';
const languages = ['en', 'de', 'fr', 'ja'];
export function LanguageSwitcher() { const { locale, setLocale, isLoading } = useI18n(); return ( <select value={locale()} disabled={isLoading()} onChange={(e) => setLocale(e.currentTarget.value)}> <For each={languages}>{(lang) => <option value={lang}>{lang}</option>}</For> </select> );}import { createI18n } from '@comvi/core';
const i18n = createI18n({ locale: 'en', fallbackLocale: 'en' });
const select = document.querySelector<HTMLSelectElement>('#language-switcher')!;select.addEventListener('change', () => { select.disabled = true; void i18n.setLocale(select.value).finally(() => { select.disabled = false; });});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:
import { i18n } from './i18n';
i18n.locale = 'fr'; // fire and forgetawait i18n.setLocale('fr'); // resolves once the locale is appliedDisabling 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.
Fallback chain
Section titled “Fallback chain”import { createI18n } from '@comvi/core';
export const i18n = createI18n({ locale: 'de', fallbackLocale: 'en',});
i18n.setFallbackLocale(['de-AT', 'de', 'en']); // tried in orderA 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().
Persist the choice
Section titled “Persist the choice”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.
React to a change
Section titled “React to a change”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;}<script setup lang="ts">import { watchEffect } from 'vue';import { useI18n } from '@comvi/vue';
const { dir, locale } = useI18n();
watchEffect(() => { document.documentElement.dir = dir.value; document.documentElement.lang = locale.value;});</script>
<template><slot /></template><template> <NuxtPage /></template>The module writes lang and dir on <html> itself, on the server and on every switch, so
there is no component to add. comvi.head: false in nuxt.config.ts turns that off.
<script lang="ts"> import { useI18n } from '@comvi/svelte';
const { dir, locale } = useI18n();
$effect(() => { document.documentElement.dir = $dir; document.documentElement.lang = $locale; });</script>import { createEffect } from 'solid-js';import { useI18n } from '@comvi/solid';
export function HtmlAttrs() { const { dir, locale } = useI18n(); createEffect(() => { document.documentElement.dir = dir(); document.documentElement.lang = 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:
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.
Refresh translations
Section titled “Refresh translations”import { useI18nLoader } from '@comvi/react';
export function RefreshButton() { const { reloadMessages } = useI18nLoader(); return <button onClick={() => reloadMessages()}>Refresh translations</button>;}<script setup lang="ts">import { useI18nLoader } from '@comvi/vue';
const { reloadMessages } = useI18nLoader();</script>
<template> <button @click="reloadMessages()">Refresh translations</button></template><script setup lang="ts">const { reloadMessages } = useI18nLoader();</script>
<template> <button @click="reloadMessages()">Refresh translations</button></template><script lang="ts"> import { useI18nLoader } from '@comvi/svelte';
const { reloadMessages } = useI18nLoader();</script>
<button onclick={() => reloadMessages()}>Refresh translations</button>import { useI18nLoader } from '@comvi/solid';
export function RefreshButton() { const { reloadMessages } = useI18nLoader(); return <button onClick={() => reloadMessages()}>Refresh translations</button>;}import { createI18n } from '@comvi/core';import { cdnLoader } from '@comvi/plugin-fetch-loader';
const i18n = createI18n({ locale: 'en' }).use(cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>' }));await i18n.init();
await i18n.reloadMessages(); // current locale and its fallback chainawait i18n.reloadMessages('de', 'dashboard'); // one namespacereloadMessages() 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.
How it works
Section titled “How it works”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.
Read next
Section titled “Read next”- Locale detector: detect the locale from the browser, a cookie, the URL or a query string.
- Load translations on demand: loaders, caching and the loading state.
- Add a locale end to end: the platform side of a new language.