Chapter 9: Internationalization
One App, Every Language
Your app ships to Cape Town, Paris, and Cairo. The prices need commas in one place and full stops in another. The dates flip their day and month. One language reads right to left. You could special-case all of it by hand, or you could let the browser do the work it already knows how to do.
tina4-js gives you a small i18n module built on two things you already have: signals and the browser's native Intl. The active locale is a signal. Translations and formatters read it. Switch the locale and every translated string and every formatted number updates in place. No reload, no locale data to download.
This chapter covers translations, number and date formatting, right-to-left text, and how to load language bundles at runtime.
Create an instance
createI18n() returns an i18n instance. Give it a locale, a fallback, and your message bundles.
import { createI18n } from "tina4js";
const i18n = createI18n({
locale: "en-US",
fallbackLocale: "en-US",
messages: {
"en-US": {
greeting: "Hello",
welcome: "Welcome, {name}!",
nav: { home: "Home", about: "About" },
},
"fr-FR": {
greeting: "Bonjour",
welcome: "Bienvenue, {name}!",
nav: { home: "Accueil" },
},
},
});Leave locale out and the instance reads navigator.language, falling back to en. The fallbackLocale is the locale consulted when a key is missing in the active one.
Translate
t(key) returns the string for the active locale. Pass a params object for {placeholder} interpolation.
i18n.t("greeting"); // "Hello"
i18n.t("welcome", { name: "Alice" }); // "Welcome, Alice!"Nested messages are flattened on the way in, and you can reach them two ways: the full dot-path, or the leaf key on its own.
i18n.t("nav.home"); // "Home"
i18n.t("home"); // "Home" -- leaf shortcutWhen a key is missing, the lookup walks a clear path: the active locale, then the fallback locale, then the key itself. It never throws. A missing translation shows you the key, which is exactly the breadcrumb you want in the console.
Switch the locale, and everything updates
The active locale is a signal. Read t() inside the reactive function form, and the moment you call setLocale() the DOM re-renders.
import { html } from "tina4js";
html`
<h1>${() => i18n.t("greeting")}</h1>
<button @click=${() => i18n.setLocale("fr-FR")}>Francais</button>
`;Click the button. The heading changes from "Hello" to "Bonjour". You wrote no update code. The ${() => ...} form is what makes it reactive: ${i18n.t("greeting")} would format once and freeze.
Format numbers, currency, and dates
Translations cover words. Numbers, money, and dates are a different problem, and the browser already solves it. The formatters delegate to Intl, so there is no formatting data to ship, and they re-render reactively just like t().
i18n.number(1234.5); // "1,234.5" (en-US) / "1.234,5" (de-DE)
i18n.currency(1999.5, "USD"); // "$1,999.50" (en-US)
i18n.date(new Date(), { dateStyle: "medium" });
i18n.relativeTime(-1, "day"); // "yesterday"Each takes the same options object the underlying Intl constructor takes, so the full power of Intl.NumberFormat and Intl.DateTimeFormat is one argument away. date() accepts a Date, an epoch in milliseconds, or a parseable string.
In a template:
html`<p class="total">${() => i18n.currency(cart.total.value, "USD")}</p>`;Right to left
Arabic, Hebrew, Farsi, and Urdu read right to left. The instance knows which locales those are. Ask it for the direction and bind it to a container.
html`<div dir=${() => i18n.dir()}>...</div>`; // "ltr" or "rtl"isRTL() returns the boolean if you need it directly. Add your own locales with the rtlLocales option when you create the instance.
Load bundles at runtime
Shipping every translation in your main bundle is wasteful when a user reads one language. Fetch a bundle on demand instead.
await i18n.loadMessages("es-ES", "/i18n/es-ES.json");
i18n.setLocale("es-ES");loadMessages fetches the JSON, merges it into the locale, and leaves the rest untouched. Pull bundles from your Tina4 backend, which has its own i18n, so the server and the browser read the same shape.
The default instance
Most apps configure one i18n. Import the default singleton and the shortcut functions, and skip passing the instance around.
import { i18n, t, setLocale } from "tina4js/i18n";
i18n.addMessages("en", { greeting: "Hello" });
i18n.addMessages("fr", { greeting: "Bonjour" });
t("greeting"); // delegates to the default instance
setLocale("fr"); // every t() in the app re-renderscreateI18n is still there when you want an isolated instance for a widget or a test.
The full API
| Method | What it does |
|---|---|
t(key, params?) | Translate, with {placeholder} interpolation |
setLocale(code) / getLocale() | Switch / read the active locale |
addMessages(locale, obj) | Merge a bundle into a locale |
loadMessages(locale, url) | Fetch a JSON bundle and merge it |
hasLocale(code) / availableLocales() | Check / list loaded locales |
number / currency / date / relativeTime | Intl-backed formatting |
isRTL() / dir() | Direction for the active locale |
locale | The active locale as a reactive signal |
One module, one signal, and the browser's own Intl engine. Your app speaks every language your users do, and it never reloads to do it.