Writing messages
The t and useI18n macros, plurals, context and markup
Anywhere — loaders, server functions, plain modules:
import { t } from 'best-i18n/macro'
const title = t`A small starter with room to grow.`
const greeting = t`Hi ${name}, you have ${count} items`Inside React components, reactive to locale changes:
import { useI18n } from 'best-i18n/react/macro'
function About() {
const t = useI18n()
return <p>{t`About`}</p>
}Both are compile-time macros: the binding can only be used as a tagged
template. Storing it, passing it around, destructuring useI18n() or shadowing
the name is a build error with a file and offset, not a runtime surprise.
A line break inside a template is code formatting, not content: it collapses
to a single space — the same rule JSX applies to <Trans> — so re-indenting a
component never changes a message or orphans its translations. Write \n for
a literal newline.
An interpolated identifier names its own placeholder, so the translator sees
Hi {name}, you have {count} items rather than Hi {0}, you have {1}.
Anything more complex than an identifier falls back to a number. A dropped or
invented placeholder in a translation is a build error naming the file, the
locale and the message.
Plurals
import { plural } from 'best-i18n/macro'
const label = plural(count, `One item`, `${count} items`)The two forms are one gettext entry — msgid and msgid_plural — and each
locale's .po supplies as many msgstr[n] forms as its Plural-Forms header
declares (Russian three, Chinese one; there is a built-in table for catalogs
that don't set the header). The compiler inlines that locale's selection
formula at the call site, so what ships is a small arrow function per plural
message — no ICU runtime, no Intl.PluralRules, and a one-form locale gets
the bare string with no dispatch at all. The count is always available to a
translation as a placeholder, interpolated or not.
Context
Two identical texts that must translate differently are different messages.
ctx is gettext's msgctxt:
const verb = t.ctx('verb')`Open` // 打开
const sign = t.ctx('adjective')`Open` // 营业中
const markup = <Trans ctx='verb'>Open</Trans>Naming the locale
A call site can name the locale it wants instead of reading the current one:
an email in the recipient's language, a preview of another locale, a label
that must stay in the source language. locale is the last modifier on t
and plural, and a prop on <Trans>:
const zh = t.locale('zh')`Hello` // always 你好
const theirs = t.locale(user.locale)`Hello` // whatever they chose
const count = plural.locale(user.locale)(n, `One item`, `${n} items`)
const rich = (
<Trans locale={user.locale}>
Read the <a href={url}>docs</a>
</Trans>
)Modifiers chain in either order: t.ctx('verb').locale('zh') and
t.locale('zh').ctx('verb') are the same message. A string literal compiles to
that locale's text alone - no branch, no locale read - and a literal that is
not one of the configured locales is a build error. Any other expression
compiles to a branch over its value at runtime. Either way the message does
not subscribe to the current locale, so it does not update when the user
switches, and in a 'use client' module on Next.js it needs no hook and no
generated component. A per-locale build leaves these branches in place: the
build fixes the current locale, not the ones a call site asks for.
Comments for the translator
A // i18n: comment directly above (or on the line of) a message becomes a
#. extracted comment in the catalogs:
// i18n: Button label on the home page, keep it short
const label = t`Save`Messages with markup
A tagged template cannot hold JSX, so a sentence with a link or a bold run in
it has nowhere to go. <Trans> is that place:
import { Trans } from 'best-i18n/react/macro'
function About() {
return (
<p>
<Trans>
Read the <a href={docsUrl}>documentation</a> to learn more.
</Trans>
</p>
)
}Svelte uses the same component and the same catalog placeholders, from
best-i18n/svelte/macro. The compiler emits {#if} around the locale
branches instead of a JSX fragment — Svelte cannot put elements inside
{...}. See Svelte.
The catalog stores the markup as named placeholders — the tag's own name where it has one, a number where it does not — for the reason Lingui established: a translator moves the pieces, and never sees a JSX attribute:
msgid "Read the <a>documentation</a> to learn more."
msgstr "请阅读<a>文档</a>了解更多。"Where this parts ways with Lingui is what runs. There is no component walking a message tree per render: each locale's version is reassembled into ordinary JSX at build time, so the above compiles to
// getLocale is injected by the compiler, from 'best-i18n/runtime'
getLocale() === 'zh' ? (
<>
请阅读<a href={docsUrl}>文档</a>了解更多。
</>
) : (
<>
Read the <a href={docsUrl}>documentation</a> to learn more.
</>
)and, under staticLocale, to the one branch on its own.
Whitespace follows JSX's own rules, so the stored message matches what renders
— including the space a line break swallows, which is why {' '} exists.
<Trans> takes no props, key included: wrap it in the element that needs
one.
<Trans> in a Client Component
A Client Component is rendered from two module graphs, and only the browser's
has an ambient locale — so a message there has to read the locale through
React, the one channel both halves share. useI18n() is that channel, and a
<Trans> sitting in JSX gets it without asking:
'use client'
import { Trans } from 'best-i18n/react/macro'
// No hook in this file, and none needed.
export function Notice({ href }: { href: string }) {
return (
<p>
<Trans>
A Client Component needs no hook for <a href={href}>this sentence</a>.
</Trans>
</p>
)
}The message becomes a component of its own, which is a legal place to call a
hook from, and the markup stays where it was — href is a local, so the
element is passed in rather than moved:
const __i18nM1 = (l, c0) =>
l === 'zh' ? <>{c0(`这句话`)}{`不需要 hook。`}</> : <>{`… for `}{c0(`this sentence`)}{`.`}</>
const __i18nT1 = (p) => __i18nM1(useLocale(), p.c0)
// at the call site
<__i18nT1 c0={(child) => <a href={href}>{child}</a>} />Nothing about the markup is deferred to runtime: the body is the same locale
ternary as everywhere else, one level down. Repeated messages share one
component, and a render prop that names nothing local (<b>, <br/>) is
hoisted to module scope and allocated once.
Two cases still need the hook. A t template has no component boundary to put
one behind, so plain t in a Client Component is a build error naming the file
and line. And a <Trans> used where a string is wanted — alt={...}, a
template literal — cannot become a component, because a component renders to
an element; the compiler only wraps a <Trans> in a position that takes one
(JSX children, or returned as it is). Calling useI18n() in the component
solves both, and is worth preferring anyway when the file already has other
messages in it.