Skip to content

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.

javascript
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.

javascript
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.

javascript
i18n.t("nav.home");   // "Home"
i18n.t("home");       // "Home"  -- leaf shortcut

When 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.

javascript
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().

javascript
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:

javascript
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.

javascript
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.

javascript
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.

javascript
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-renders

createI18n is still there when you want an isolated instance for a widget or a test.

The full API

MethodWhat 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 / relativeTimeIntl-backed formatting
isRTL() / dir()Direction for the active locale
localeThe 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.

Sponsored with 🩵 by Code InfinityCode Infinity