The t() escape hatch
Some strings don't live in JSX. A button-label array fed into a .map, an error message returned from a reducer, a tooltip stored in a ref — the extractor can't see strings hidden behind expressions.
Use t() to mark them for extraction without leaving the translator's flow.
The pattern
import { t } from "@neokapi/kapi-react/runtime";
const UI_LANGUAGES = [
{ value: "en", label: t("English") },
{ value: "qps", label: t("Pseudo English (qps)") },
];
const THEMES = [
{ value: "system", icon: Monitor, label: t("System") },
{ value: "light", icon: Sun, label: t("Light") },
{ value: "dark", icon: Moon, label: t("Dark") },
];
function greet(user: User) {
return t("Hello, {name}!", { name: user.displayName });
}
At build time the plugin rewrites every t("...") call bound to @neokapi/kapi-react/runtime into a hash-based lookup:
// Input
t("English");
// Output (runtime mode)
__t("aB3xZ", "English");
In dev mode (plugin not active) t is a no-op that returns the source text verbatim, with {name} substitutions applied. So you can use it unconditionally — tests, SSR, storybook, dev server: all fine.
Why a separate marker?
kapi-react's promise is zero wrappers for JSX. JS data structures are different — the extractor has no AST-level signal that label: "English" is a translatable string rather than an ID, an enum value, a CSS class, or anything else.
t() is the explicit "treat this as translatable" marker for that context. It's the minimum necessary handoff — one function call per string — and it keeps the JSX story wrapper-free.
Parameters
t("Hello, {name}!", { name: "Alice" });
// → "Hello, Alice!" in dev mode
// → translation with {name} substituted at runtime in production
Parameter syntax mirrors what the JSX extractor uses ({name}), so a translator editing an entry sees the same placeholder shape whether it came from JSX or t().
Context — disambiguating identical source strings
Some strings are spelled the same in English but mean different things. A CAT tool showing "State" out of nowhere gives a translator no way to know whether it means a US state, a workflow status, or a physics state.
Pass a positional context as the second argument:
t("State", "US state"); // → address form field
t("State", "workflow status"); // → task lifecycle
t("State", "physics lecture"); // → h / cold / gas / plasma
Each of those is a separate block with a different hash, so translators can give each one its own target string.
With params, context comes first:
t("Hello, {name}!", "greeting", { name: user.name });
Context only affects the hash at extract / transform time. It's stripped from the emitted __t() call and never ships to the runtime — the hash already encodes the disambiguation.
Context mirrors gettext's msgctxt for teams familiar with the pattern.
Import-name tracking
The plugin only rewrites t identifiers bound to @neokapi/kapi-react/runtime. A local helper named t or a t imported from a different library is left alone:
import { t } from "@neokapi/kapi-react/runtime";
import { t as styled } from "styled-components"; // ← unrelated
const Wrapper = styled.div`...`; // ← not rewritten
const label = t("Hello"); // ← rewritten to __t("hash", "Hello")
Aliases work too:
import { t as tr } from "@neokapi/kapi-react/runtime";
const label = tr("Hello"); // ← rewritten
Where the hash comes from
t() calls hash on a separate channel from JSX extraction:
hash = hashKey(text, "t\x1F")
So t("Save") and <button>Save</button> produce different hashes. That's intentional: the JSX call site has structural context (inside a button, inside a form, etc.) that a standalone string doesn't. A translator might want German "Speichern" for the button and "Gespeichert!" for a toast's t("Saved") — separating channels lets them diverge.
Module-level t() gotcha
t() reads the active dictionary at call time. A module-level
const evaluates once, at import — typically before the app has
finished calling loadTranslations(). The const freezes at the
fallback language forever:
// ✗ Frozen at load time. "Utility" still says "Utility" in pseudo.
const categoryMeta = {
utility: { label: t("Utility") },
pipeline: { label: t("Pipeline") },
};
Fix: wrap the lookup in a function that runs per render. Each call reads the current dict:
// ✓ Per-render resolution.
function categoryMeta(cat: string) {
switch (cat) {
case "utility":
return { label: t("Utility") };
case "pipeline":
return { label: t("Pipeline") };
// …
}
}
function Chip({ cat }: { cat: string }) {
const meta = categoryMeta(cat);
return <span translate="no">{meta.label}</span>;
// ^ prevents double-wrap; see below.
}
Why the translate="no"? If the parent would be extractable on
its own (has static text, inline children, etc.), it'd wrap the
already-translated meta.label in a second translation layer,
showing ▒ ▒ Utility ▒ ▒ in pseudo. translate="no" tells the
extractor the inner t() is the single source of truth for this
subtree. See Writing components → Double-translation.
Ternary children with string literals
kapi-react treats the whole JSXExpressionContainer as one
placeholder — it never looks inside a ternary at its branches:
// ✗ Neither "Saving..." nor "Save" gets extracted.
<Button>{saving ? "Saving..." : "Save"}</Button>
Wrap each branch with t():
<Button>{saving ? t("Saving...") : t("Save")}</Button>
Template literals with static copy inside: same treatment.
// ✗ template never extracts
<span>{count > 0 ? `Loading ${count}...` : "Idle"}</span>
// ✓ placeholder-aware t()
<span>
{count > 0 ? t("Loading {count}...", { count }) : t("Idle")}
</span>
Purely-format templates (no alphabetic text: `${pct}%`,
`v${version}`) don't need t() — they're code-level
formatting, not UI copy — and the lint rule
no-ternary-literals-in-jsx-child
knows not to flag them.
When to use t() vs. refactor to JSX
Sometimes the cleanest fix is to hoist the string into JSX instead:
// Data-driven, needs t()
const THEMES = [
{ value: "system", label: t("System") },
{ value: "light", label: t("Light") },
];
// Unrolled, no t() needed
<button onClick={() => setTheme("system")}>System</button>
<button onClick={() => setTheme("light")}>Light</button>
Heuristics:
- 3 items or fewer, and the render is a simple
.map→ unrolling is usually clearer and removes thet()calls. - Data lives in a module other than the one rendering it, or is assembled dynamically → use
t(). - The data already carries non-string metadata (icons, callbacks, IDs) → keep it as data, use
t()for labels.
Runtime fallback behaviour
In prod (plugin active), __t(hash, fallback, params) does:
- Look up
hashin the loaded dict. - Resolve ICU plural / select forms if present.
- Substitute
{name}tokens. - Return the translated string (or the fallback if no entry).
In dev (plugin not active), t(text, params) does:
- Substitute
{name}tokens in the source text. - Return it.
Both return a string. No ReactNode result — for that you need the JSX path.
ESLint / oxlint: keep t() honest
t(someVariable) defeats the point — the extractor has no text to hash. Install @neokapi/kapi-react-lint which ships rules for both ESLint and oxlint that catch this and the related pitfalls (t('Hello ' + name), <img alt={'Logo ' + brand} />, string literals hidden in JSX expression containers).
Next
- Plurals and select — the other pattern where you need explicit markers (for the plural/case authoring components).
- Pipeline — how
t()blocks flow through extract/translate/compile alongside JSX blocks.