Split translations into namespaces
Split the catalog into namespaces and load each one only on the screens that use it. This
builds on the loader from Load translations on demand;
without one, namespaces still work as locale:namespace keys in a bundled catalog.
Define namespaces
Section titled “Define namespaces”import { createI18n } from '@comvi/core';import { loader } from '@comvi/core/loader';
export const i18n = createI18n({ locale: 'en', defaultNs: 'default', ns: ['default', 'dashboard'],}).use( loader({ 'en': () => import('./locales/en.json'), 'en:dashboard': () => import('./locales/dashboard/en.json'), 'en:settings': () => import('./locales/settings/en.json'), 'de': () => import('./locales/de.json'), 'de:dashboard': () => import('./locales/dashboard/de.json'), 'de:settings': () => import('./locales/settings/de.json'), }),);A namespace is a name; the loader map decides where its content comes from. Each map key is
locale for the default namespace or locale:namespace for another one, and each value is a
dynamic import(), so the bundler splits every namespace into its own chunk.
| Option | Default | Effect |
|---|---|---|
defaultNs |
'default' |
The namespace t() reads when the call names none. |
ns |
[defaultNs] |
Namespaces to load during init(). ns: [] loads none. |
ns needs the loader capability: on an instance with no .use(loader(…)) the option loads
nothing, and development logs a warning that says so.
The Comvi CDN uses the same layout: the project’s default namespace at en.json and every
other namespace in its own folder. When the app’s defaultNs differs from the namespace the
platform keeps at the root, set cdnLayout.rootNamespace on the
fetch loader.
import { createI18n } from '@comvi/core';import { cdnLoader } from '@comvi/plugin-fetch-loader';
export const i18n = createI18n({ locale: 'en', defaultNs: 'storefront', ns: ['storefront', 'default'],}).use( cdnLoader({ cdnUrl: 'https://cdn.comvi.io/<project-token>', cdnLayout: { rootNamespace: 'default' }, }),);Read a namespaced key
Section titled “Read a namespaced key”import { useI18n } from '@comvi/react';
export function Dashboard() { const { t, isLoading } = useI18n('dashboard');
if (isLoading) return <div>Loading...</div>;
return ( <> <h1>{t('page.title')}</h1> <p>{t('stats.summary')}</p> <button>{t('logout', { ns: 'auth' })}</button> </> );}useI18n('dashboard') makes dashboard the namespace for every t() in the component, and
{ ns } on one call still reads another. With generated keys the scoped t() accepts only that
namespace’s keys. On the instance, i18n.t('page.title', { ns: 'dashboard' }) does the same.
There is no namespace:key shorthand: t('dashboard:welcome') reads the literal key
dashboard:welcome in the default namespace.
Scoping loads nothing. The namespace has to be in ns, or loaded at runtime.
Load a namespace at runtime
Section titled “Load a namespace at runtime”import { i18n } from './i18n';
// A route loader runs before its component renders, so the namespace is in// place by first paint. Hand the array to createBrowserRouter().export const routes = [ { path: '/settings', loader: async () => { await i18n.addActiveNamespace('settings'); return null; }, },];addActiveNamespace() loads the namespace for the current locale and for each locale in
fallbackLocale, and keeps it active, so a later locale switch fetches it for the new locale too. addActiveNamespaces([...]) takes
several at once. Both live on the loader capability: .use(loader(map)) or
.use(cdnLoader({ cdnUrl })) on the instance, or useI18nLoader() in a component.
import { useEffect } from 'react';import { useI18nLoader } from '@comvi/react';
export function SettingsGate() { const { addActiveNamespace } = useI18nLoader(); useEffect(() => { void addActiveNamespace('settings'); }, [addActiveNamespace]); return null;}A namespace a screen keeps needing goes through addActiveNamespace() rather than a one-off
addMessages(): the first follows the user across locales, the second adds content for one
locale only.
Deactivate a namespace
Section titled “Deactivate a namespace”import { i18n } from './i18n';
i18n.removeActiveNamespace('settings');removeActiveNamespace() is synchronous. It drops the namespace from the active set, cancels
its in-flight loads, and leaves the cache alone, so content already loaded keeps resolving.
Dropping content is a separate call, clearMessages(undefined, 'settings'), which never
deactivates anything.
Static catalogs
Section titled “Static catalogs”import { createI18n } from '@comvi/core';import defaultEn from './locales/en.json';import dashboardEn from './locales/dashboard/en.json';import defaultDe from './locales/de.json';import dashboardDe from './locales/dashboard/de.json';
export const i18n = createI18n({ locale: 'en', defaultNs: 'default', messages: { 'en:default': defaultEn, 'en:dashboard': dashboardEn, 'de:default': defaultDe, 'de:dashboard': dashboardDe, },});A bundled catalog takes the same locale:namespace keys. Every namespace in messages is
active from the start, so ns is only needed when a loader should fetch more during init().
The imported files may be flat or nested; the instance flattens nested JSON on ingestion.
How it works
Section titled “How it works”A namespace is a second coordinate on the cache. Every catalog is stored under a
locale:namespace key, and t() picks one slot per call: the locale is params.locale or the
current one, the namespace is params.ns or defaultNs, and useI18n('dashboard') is a t()
with ns: 'dashboard' filled in. The key itself is never parsed for a namespace, which is why
t('dashboard:welcome') is a lookup for a key with a colon in it in the default namespace.
A { ns } on one call overrides the scope for that call alone, and the lookup stays in that
namespace all the way down: the fallback locale chain is walked for the same namespace, and the
missing-key path reports that namespace.
While a slot is empty, because the namespace is still loading or was never activated, a key
from it goes down the same path as any other missing key. It walks the fallback locales in the
same namespace, emits missingKey and renders as itself. That is why the isLoading check in
Dashboard is what keeps the first paint from showing raw keys. Once the catalog lands,
namespaceLoaded fires and every component that read the instance re-renders with the text.
The active set is what the loader works from. init() activates every namespace in ns and
loads each one for the current locale and for each locale in fallbackLocale, in parallel,
before it resolves. setLocale('de') fetches de for every namespace in the set and nothing
else: the set is the list of namespaces the user currently needs, so a switch brings all of them
along. addActiveNamespace() adds one and loads it the way init() does;
removeActiveNamespace() drops it and cancels its in-flight loads but keeps its cache entries,
so a screen the user comes back to renders from the cache. addMessages() activates every
namespace it carries; a loader refetch writes to the cache without activating anything. With
nothing active, reloadMessages() falls back to the default namespace and says so in
development; a locale switch has no such fallback and fetches nothing.
Concurrent requests for one (locale, namespace) pair share one loader call, and a namespace
whose load fails stays active, so the next locale switch or reload retries it. A namespace
outside the active set and outside any messages catalog is unknown to the instance until
something activates it, and its keys render as themselves.
On the Comvi CDN the default namespace is the file at the root, en.json, and every other
namespace lives in a folder of its own, so the map the CDN loader builds has the same shape as
an import map. cdnLayout.rootNamespace names which namespace the root file is when the app’s
defaultNs is a different one.
Read next
Section titled “Read next”- Load translations on demand: loaders, caching and reload.
- Translate text with t():
t(), call options and missing keys. @comvi/core/loader: every method on the loader capability.