Dynamic loading
Load locale catalogues lazily to keep bundles small
A catalogue's messages takes an entry per locale, and each entry is written one of two ways:
- Inline, the messages themselves. The locale is bundled, ready to use.
- A thunk, a function producing them. The locale loads on demand, the first time it is asked for.
Most production apps mix the two, inline one locale and lazy-load the rest. This guide covers the patterns.
The trade-off
Inline
Pros: zero loading state, always-available, simpler runtime.
Cons: every locale ships in the bundle (or initial chunk).
Best for: small numbers of locales, server-rendered apps, CLIs.
Thunk
Pros: only the locales you use are fetched, smaller initial load.
Cons: async transitions on locale switch, requires bundler code-splitting.
Best for: many locales, large catalogues, browser-only apps.
Inline pattern
The simplest setup:
import { createCatalogue } from 'saykit';
import en from './locales/en.po';
import fr from './locales/fr.po';
export const catalogue = createCatalogue({ en, fr });Both catalogues are part of the bundle. catalogue.locale('fr') is synchronous, no loading state ever.
Thunk pattern
import { createCatalogue } from 'saykit';
export const catalogue = createCatalogue({
en: () => import('./locales/en.po'),
fr: () => import('./locales/fr.po'),
ja: () => import('./locales/ja.po'),
de: () => import('./locales/de.po'),
});Bundlers turn each dynamic import() into its own chunk. A thunk resolves to a module rather than to the messages themselves, and the catalogue reads them off its default export, so there is nothing to unwrap by hand. load() calls the thunk and hands back the locale's view:
const say = await catalogue.load('fr');Writing one thunk per locale rather than one function keyed by locale is deliberate: every import is a literal a bundler can see, so the set of shipped locales is decided at build time, and there is no locale the catalogue lists but cannot produce.
babel-plugin-saykit currently rewrites static import en from './locales/en.po' only. For
dynamic imports, the bundler does the work, the formatter still parses the file, but through the
bundler's loader pipeline rather than SayKit's. This works fine for unplugin-saykit (Vite,
Rolldown, Rollup, Webpack, …).
Hybrid pattern
Inline the source locale, lazy-load everything else:
import { createCatalogue } from 'saykit';
import en from './locales/en.po';
export const catalogue = createCatalogue({
en,
fr: () => import('./locales/fr.po'),
ja: () => import('./locales/ja.po'),
de: () => import('./locales/de.po'),
});Initial render is synchronous, the source locale is always there. Switching locales triggers a single chunk load, and you control when:
const say = await catalogue.load(locale);In a browser, this is what a store does for you: store.set(locale) loads, swaps and notifies.
Preloading on hover
In a locale switcher, prefetch the catalogue on hover so the actual switch is instant:
function LocaleOption({ locale }: { locale: string }) {
return (
<a href={`/${locale}`} onMouseEnter={() => catalogue.load(locale)}>
{locale}
</a>
);
}load() on a locale that already has its messages never goes near its thunk and returns the view synchronously, so this is safe to call repeatedly, and two loads that overlap share one call rather than fetching twice. A thunk is called again only when it failed, whether it threw outright or returned a promise that rejected.
Server-side
On the server, you usually load only the locale this request needs:
import { createCatalogue } from 'saykit';
export const catalogue = createCatalogue({
en: () => import('./locales/en.po'),
fr: () => import('./locales/fr.po'),
ja: () => import('./locales/ja.po'),
de: () => import('./locales/de.po'),
});
export function getSayFor(locale: string) {
return catalogue.load(catalogue.match(locale));
}There is nothing to isolate. The catalogue has no current locale to leak between requests, and the view it hands back cannot be changed, so it is safe to pass anywhere. To pay the imports once at boot instead of on the first request that needs each locale:
await Promise.all(catalogue.locales.map((locale) => catalogue.load(locale)));Sync thunks
A thunk doesn't have to return a promise. If you have everything available synchronously, say you build catalogues at request start, or you're in a CLI, return the messages directly:
export const catalogue = createCatalogue({
en: () => buildCatalogueFor('en'), // sync
fr: () => buildCatalogueFor('fr'),
});
catalogue.load('fr'); // returns the view, not a promiseThe work is still deferred to the first time each locale is used, but nothing downstream has to await.
Next
- Runtime, the full catalogue and view API
- Locale detection, deciding what to load