Skip to content

i18n

A minimalist i18n (≈ 1.5KB gzip) built into the SDK. It covers the common case — 2 or 3 languages, interpolation and simple plurals — without the weight of a full-blown library.

Why a built-in i18n?

Most Tempest apps need only PT-BR + EN with a few dozen strings. Pulling in i18next (with its backend, detector and formatter plugins) for that adds kilobytes and configuration nobody will maintain. The SDK ships just enough for the cheap, simple case — and steps aside when you need more.

The catalog

Everything starts with a catalog: a { [locale]: { [key]: "text" } } map. The keys are yours — use flat names, namespaces ("auth.login"), or whatever matches your string pipeline. The SDK enforces no schema.

// src/i18n.ts
import type { Catalog } from "tempest-react-sdk";

export const messages: Catalog = {
  "pt-BR": {
    greet: "Olá, {name}",
    "nav.home": "Início",
    alos_one: "{count} Alô",
    alos_other: "{count} Alôs",
  },
  en: {
    greet: "Hi, {name}",
    "nav.home": "Home",
    alos_one: "{count} Alo",
    alos_other: "{count} Alos",
  },
};

Mounting the provider

Wrap your tree in I18nProvider, passing the initial locale, an optional fallbackLocale and the catalog. Here is a complete, runnable app:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { I18nProvider } from "tempest-react-sdk";
import "tempest-react-sdk/styles.css";
import { messages } from "./i18n";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <I18nProvider locale="pt-BR" fallbackLocale="en" messages={messages}>
      <App />
    </I18nProvider>
  </StrictMode>,
);

Free: <html lang> and persistence

I18nProvider writes <html lang="pt-BR"> automatically (good for SEO and screen readers) and persists the user's choice in localStorage["tempest-locale"]. To disable persistence, pass storageKey={null}.

Translating in the UI

useI18n() returns the translation helpers plus setLocale, locale and availableLocales. A complete component:

import { useI18n } from "tempest-react-sdk";

export function Header() {
  const { t, plural, formatNumber, locale, availableLocales, setLocale } = useI18n();

  return (
    <header>
      <p>{t("greet", { name: "Mau" })}</p> {/* "Olá, Mau" */}
      <p>{plural("alos", 3)}</p> {/* "3 Alôs" */}
      <p>{formatNumber(1234.5, { style: "currency", currency: "BRL" })}</p> {/* "R$ 1.234,50" */}
      <select value={locale} onChange={(event) => setLocale(event.target.value)}>
        {availableLocales.map((code) => (
          <option key={code} value={code}>
            {code}
          </option>
        ))}
      </select>
    </header>
  );
}

When you only need t, use the useTranslate() shortcut — it avoids the destructuring:

import { useTranslate } from "tempest-react-sdk";

function NavHome() {
  const t = useTranslate();
  return <a href="/">{t("nav.home")}</a>;
}

useI18n requires the provider

Calling useI18n() (or useTranslate()) outside an <I18nProvider> throws useI18n must be used inside an <I18nProvider>. Keep the provider above any component that translates.

useOptionalI18n() for code that must work without a catalog

Returns null outside the provider instead of throwing. That is what a reusable piece uses when it wants to be translated where a catalog exists and still work where one does not — i18n is opt-in in this SDK, and demanding a provider would turn "you did not configure translations" into a crash. useDescribeApiError in the http module is exactly that case.

const i18n = useOptionalI18n();
const title = i18n?.t("dashboard.title") ?? "Dashboard";

Interpolation

Placeholders in the {name} form are replaced by the values in params. A missing key falls back to fallbackLocale; if it is missing there too, the helper returns the key itself (it never blanks out the screen). A placeholder with no value stays literal — {name} — making the forgotten string easy to spot.

t("greet", { name: "Ana" }); // "Olá, Ana"
t("greet"); // "Olá, {name}"  (no params → literal placeholder)
t("missing"); // "missing" (no fallback → the key itself)

Default text per key

When a key exists in neither locale, t returns the key itself — great for spotting a forgotten string in development, terrible for the user, who reads cart.empty on screen. The third argument fixes that:

t("cart.empty", undefined, { default: "Your cart is empty" });
// catalog defined it     → the translation
// catalog did not        → "Your cart is empty"

The default is interpolated like any other message, so it carries placeholders:

t("cart.count", { n: 3 }, { default: "{n} items" }); // "3 items"

plural takes the same, after both suffixed lookups:

plural("boxes", 2, undefined, { default: "{count} boxes" }); // "2 boxes"

Do not detect the miss by comparing against the key

The tempting shape is const v = t(k); const text = v === k ? myDefault : v;. That is wrong for a catalog which legitimately maps a key to itself — { "cart.empty": "cart.empty" }, which is what a machine-generated or placeholder catalog produces — and there your default beats a translation that was actually there.

Only the i18n layer knows whether there was a miss, because only it saw the catalog. Pass default and let it answer. This is the path the SDK's own internal strings take (tempest.error.offline, in useDescribeApiError).

Plurals

plural(key, count, params?) picks between ${key}_one (when count === 1) and ${key}_other (any other value), with {count} available for interpolation:

plural("alos", 1); // "1 Alô"   → uses alos_one
plural("alos", 5); // "5 Alôs"  → uses alos_other

Rich plurals? Reach for i18next

This _one / _other scheme covers PT-BR and EN. Languages with more plural categories (Russian, Polish, Arabic) need Intl.PluralRules — not worth reimplementing here. For those, switch to i18next directly. The SDK assumes the simple case on purpose.

Without React (imperative)

createI18n is the foundation; the provider just layers React state on top. Use it in utilities, tests or loaders outside the component tree:

import { createI18n } from "tempest-react-sdk";
import { messages } from "./i18n";

const i18n = createI18n({ locale: "pt-BR", fallbackLocale: "en", messages });

i18n.t("greet", { name: "Mau" }); // "Olá, Mau"
i18n.formatDate(new Date(), { dateStyle: "long" });

const en = i18n.withLocale("en"); // new I18n, same catalog
en.t("greet", { name: "Mau" }); // "Hi, Mau"

Dynamic loading

Load one JSON per locale and pass it to the provider. To code-split by route, swap messages via state — missing keys fall back while the rest loads:

const ptBR = await fetch("/i18n/pt-BR.json").then((response) => response.json());

Recap

  • The catalog is a { locale: { key: "text" } } map — free-form keys, no schema.
  • I18nProvider takes locale, fallbackLocale and messages; it writes <html lang> and persists to localStorage["tempest-locale"] (disable with storageKey={null}).
  • useI18n() exposes t, plural, formatNumber, formatDate, setLocale, locale, availableLocales; useTranslate() is the t-only shortcut.
  • t interpolates {placeholder}, falls back, and finally returns the key itself — it never breaks.
  • plural uses the _one / _other suffixes; complex plurals call for i18next.
  • createI18n runs outside React; withLocale clones the object into another language.

See also

  • Theme — combining with <html lang> is free
  • App Providers — mounting i18n alongside Query, Theme and ErrorBoundary
  • UtilsformatCurrency / formatDate for PT-BR directly