Plurals & formatting
Locale-aware plural selection (CLDR via Intl.PluralRules) and Intl-backed formatters — plural, usePlural, useFormatDate, useRelativeTime, useFormatNumber, useFormatCurrency, useFormatters.
Two things go wrong in every app that ships useLocale() but no formatters.
The first is pluralization by string surgery: `${count} epic(s)`. That literal (s) is a guess that only reads as acceptable in English — and it isn't even correct there ("1 epic(s)"). Outside English and German it is simply wrong: Polish needs three forms for what English does with two, and no amount of parentheses expresses that.
The second is relative time. "3 minutes ago" looks trivial, so it gets written inline — and then again in another component, and again with a date library, until one app carries four divergent helpers, one of them hardcoded German. They disagree on rounding, on the sub-second case, and on the language.
@voltro/i18n closes both with Intl-backed primitives that resolve the active locale from the provider. Nothing to pin, nothing to hand-roll, and no dependency — Intl.PluralRules / DateTimeFormat / NumberFormat / RelativeTimeFormat are in every runtime the framework targets.
plural — the pure core
import { plural } from '@voltro/i18n'
plural('en', 1, { one: '{count} epic', other: '{count} epics' }) // "1 epic"
plural('en', 3, { one: '{count} epic', other: '{count} epics' }) // "3 epics"
plural('en', 0, { one: '{count} epic', other: '{count} epics' }) // "0 epics"plural(locale, count, forms, options?) selects the form using the locale's real CLDR rules via Intl.PluralRules, then substitutes every {count} occurrence. It takes the locale as an argument and touches no React, so it works in meta({ locale }), in a server handler, or in a test — the hook below is a thin binding of it.
forms accepts zero, one, two, few, many and other. Only other is required: it is the fallback for every category the caller didn't supply and for every category a locale doesn't distinguish.
One/other is not enough — the Polish proof
const files = {
one: '{count} plik',
few: '{count} pliki',
many: '{count} plików',
other: '{count} pliku',
}
plural('pl', 1, files) // "1 plik"
plural('pl', 3, files) // "3 pliki" → few
plural('pl', 7, files) // "7 plików" → manyPolish distinguishes few (2–4) from many (5+). This is the exact case a hardcoded (s) or a hand-written count === 1 ? a : b cannot express — and it is not an exotic edge case, it is a language with 40 million speakers. Supply the categories the locale needs; the ones you omit fall through to other:
plural('pl', 3, { one: '{count} epic', other: '{count} epics' }) // "3 epics" — no `few` givenExplicit zero
plural('en', 0, { one: '{count} epic', other: '{count} epics', zero: 'no epics' }) // "no epics"
plural('en', 1, { one: '{count} epic', other: '{count} epics', zero: 'no epics' }) // "1 epic"zero is honoured for an exact 0 even in locales whose CLDR category for 0 is other (English). Apps overwhelmingly want "no items" there rather than "0 items", and opting out is just omitting the key.
Ordinals
Pass Intl.PluralRules options through as the fourth argument:
const ord = { one: '{count}st', two: '{count}nd', few: '{count}rd', other: '{count}th' }
plural('en', 1, ord, { type: 'ordinal' }) // "1st"
plural('en', 2, ord, { type: 'ordinal' }) // "2nd"
plural('en', 3, ord, { type: 'ordinal' }) // "3rd"
plural('en', 4, ord, { type: 'ordinal' }) // "4th"An unknown locale tag falls back to other instead of throwing — a stale cookie renders English-ish output, not a crash.
The hooks
Every hook below reads the active locale from the provider via useLocale() and returns a stable callback.
usePlural
plural bound to the active locale — same (count, forms, options?) signature minus the leading locale:
import { usePlural } from '@voltro/i18n'
function EpicCount({ count }: { readonly count: number }) {
const plural = usePlural()
return <span>{plural(count, { one: '{count} epic', other: '{count} epics', zero: 'no epics' })}</span>
}useFormatDate
const formatDate = useFormatDate()
formatDate(order.createdAt, { dateStyle: 'medium' })
formatDate(order.createdAt, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' })(value, options?) => string, where value is a Date, a timestamp number, or a date string, and options is Intl.DateTimeFormatOptions.
The zone comes from the provider, which you configure once — timeZone in the web app.config.ts (see Timezones under SSR). Pass timeZone in the options only when the value genuinely belongs to a fixed zone regardless of who is looking (a store's opening hours, a scheduled broadcast); that overrides the provider for one call.
With timeZone unset in app.config.ts there is no zone at all, and each runtime falls back to its own. On a server-rendered page that is the pod's zone for the markup and the viewer's for the hydration render — a mismatch on every timestamp, and a different calendar day across midnight. useTimeZone() returns undefined in exactly that state, so you can assert on it.
useTimeZone
const timeZone = useTimeZone() // 'Europe/Berlin' — or undefined when none is pinnedundefined is a real answer and worth branching on: it means nobody decided, so the server and the browser are each using their own zone. It is not the same as 'UTC'.
useRelativeTime
const relativeTime = useRelativeTime()
relativeTime(comment.postedAt) // "3 minutes ago" / "vor 3 Minuten"
relativeTime(job.runsAt) // "in 2 days"
relativeTime(comment.postedAt, { numeric: 'always' }) // "1 day ago" instead of "yesterday"
relativeTime(comment.postedAt, { now: renderedAt }) // measure against a fixed base(value, options?) => string. Options are Intl.RelativeTimeFormatOptions plus a now override (a Date, number, or string) for deterministic rendering and tests.
Hydration-safe by default. Without an explicit now, the base is the server's render instant for the server render and the hydration pass that has to match it — published as <html data-voltro-now> — then the live clock once hydration commits. So "3 minutes ago" cannot become "4 minutes ago" between the HTML and the first client render just because the network was slow, which is what a plain Date.now() base does whenever a unit boundary falls in the gap.
It picks the largest unit that fits, so a 90-minute delta reads "1 hour ago", not "90 minutes ago". Anything under a second renders through the second unit at 0 — "now" — which avoids the "0 seconds ago" flicker hand-rolled versions produce. numeric: 'auto' is the default, so English gets "yesterday" rather than "1 day ago".
It is not a drop-in replacement for a hand-rolled helper. Apps that wrote their own usually picked abbreviated, app-specific wording — "2 hr ago", "vor 2 Std." — whereas this hook emits Intl.RelativeTimeFormat output: "2 hours ago" / "vor 2 Stunden". Adopting it is a visible copy change, so treat it as a design decision rather than a find-and-replace. What you get in exchange is every locale for free and the unit-selection edge cases handled; what you give up is control over the exact phrasing.
useFormatNumber and useFormatCurrency
const formatNumber = useFormatNumber()
formatNumber(1234.5) // "1,234.5" / "1.234,5"
formatNumber(0.42, { style: 'percent' }) // "42%"
formatNumber(1_200_000, { notation: 'compact' }) // "1.2M"
const formatEur = useFormatCurrency('EUR')
formatEur(19.9) // "€19.90" / "19,90 €"
formatEur(19.9, { maximumFractionDigits: 0 }) // options merge over the currency defaultsuseFormatNumber() is (value, options?) => string over Intl.NumberFormatOptions. useFormatCurrency(currency) takes the ISO code up front and applies { style: 'currency', currency }; any options you pass are merged on top, so you can still override fraction digits or notation.
Note that the currency code is not the locale — useFormatCurrency('EUR') renders €19.90 for an English viewer and 19,90 € for a German one. The amount's currency and the viewer's language are independent, and this keeps them that way.
useFormatters
For a component that needs several at once, without stacking five hook calls:
import { useFormatters } from '@voltro/i18n'
function ActivityRow({ entry }: { readonly entry: Entry }) {
const { locale, timeZone, formatDate, relativeTime, formatNumber, plural } = useFormatters()
return (
<li lang={locale}>
<time dateTime={entry.at.toISOString()} title={formatDate(entry.at, { dateStyle: 'full' })}>
{relativeTime(entry.at)}
</time>
{plural(entry.changes, { one: '{count} change', other: '{count} changes' })}
<span>{formatNumber(entry.score)}</span>
</li>
)
}It returns the active locale and timeZone plus formatDate, relativeTime, formatNumber and plural — memoized together. Currency is not in the bundle because it needs its ISO code up front; call useFormatCurrency(code) alongside it when you need one.
Timezones under SSR — the setting that is not a preference
A formatter is deterministic given the value, the locale, the zone and the clock. The provider supplies all four, and the two beyond locale are the ones that differ between the server and the browser:
| Where it came from before | What that means under SSR | |
|---|---|---|
| locale | the provider, both sides | agreed already — the server publishes <html lang> and the client reads the attribute rather than navigator.languages |
| zone | the runtime | the POD on the server (UTC on a container with no TZ), the VIEWER's machine in the browser |
| clock | Date.now() |
two numbers, differing by the network latency |
So a server-rendered timestamp was a hydration mismatch (React error #418) waiting for a wide enough offset or a slow enough connection, and across midnight it was a different calendar day. The fix is the one the locale already used: the server decides, publishes its answer, and the client reads the answer instead of forming its own.
Configure it once
// apps/<project>/web/app.config.ts
export default {
type: 'web' as const,
name: 'myApp',
port: 5191,
locales: ['de', 'en'] as const,
defaultLocale: 'de' as const,
timeZone: 'Europe/Berlin' as const, // one zone for every viewer
// …or:
// timeZone: 'viewer' as const, // resolve per request, per user
// defaultTimeZone: 'UTC' as const, // before the viewer's zone is known
}Whatever it resolves to is stamped on the document as <html data-voltro-tz>, and the generated client entry reads that attribute. Both sides then format against one value — which is the property that removes the mismatch, whether or not the value is the viewer's true zone. Being wrong together is repairable after mount; being different is not.
timeZone requires locales, because the zone rides the <I18nProvider> the framework generates from it.
timeZone: 'viewer' — how the server learns the zone
Through the voltro:tz cookie, which has two writers and wants both:
- The framework's script, injected into
<head>, seeds it fromIntl.DateTimeFormat().resolvedOptions().timeZonewhen the cookie is absent. From the second request onward the server renders in the browser's zone with no login and no app code. It never overwrites an existing value and never reloads the page. - Your app, at login — overwriting it with the zone you hold for the signed-in user. That is the authoritative one: a profile field or an identity provider's
timeZoneclaim beats the machine a user happens to be sitting at.
Write it from middleware.ts, which runs per request and can return cookies:
// apps/<project>/web/middleware.ts
import { defineMiddleware } from '@voltro/web/middleware'
import { TIMEZONE_COOKIE, isSupportedTimeZone } from '@voltro/i18n'
export const userTimeZone = defineMiddleware({
run: async (req) => {
const zone = await zoneForSession(req.cookies) // your session → the user's own zone
if (!isSupportedTimeZone(zone) || req.cookies[TIMEZONE_COOKIE] === zone) return undefined
return {
setCookies: [
{ name: TIMEZONE_COOKIE, value: zone, path: '/', maxAge: 31_536_000, sameSite: 'lax' as const },
],
}
},
})A cookie set here is applied to the jar the SAME render reads, so the zone takes effect on the response that sets it rather than the one after.
Validate before you write. An unusable zone is dropped on the way in (a stale cookie, a typo in the config, a runtime with a trimmed ICU) rather than forwarded — Intl.DateTimeFormat throws on an unknown zone, and one bad value would otherwise degrade every timestamp in the app to a raw Date string.
Prerendered pages
A renderMode: 'static' page is one artefact for every viewer, so 'viewer' cannot mean the viewer there — it resolves to defaultTimeZone. The build publishes that value and its own build instant, so the markup and the first client render still agree; a relative time in a prerendered page corrects itself in one frame after mount rather than mismatching.
Formatters vs. ICU in the catalog
Both can pluralize, and they are not competitors — pick by where the string lives:
- ICU in the catalog (
'{count, plural, one {# item} other {# items}}') is right when the whole sentence is translator-owned. Translators see the plural structure in their tool and can add the categories their language needs without a code change. This is the default for user-facing prose. plural/usePluralis right when the forms are decided in code — a pure helper outside React, ameta({ locale })title, a test asserting CLDR behaviour, or a count rendered next to non-string content.
For dates, numbers and relative time the hooks are the blessed path; reach for react-intl's <FormattedDate> / <FormattedNumber> only when you want the JSX form.
Anti-patterns
- Don't write
(s),count === 1 ? 'x' : 'xs', or a+ 's'suffix. It is wrong in most languages and cannot be fixed by a translator. Useplural/usePluralor ICU in the catalog. - Don't hand-roll "X minutes ago".
useRelativeTimeis one hook, is localized, and handles the sub-second and unit-selection cases that inline versions get wrong. - Don't pass
timeZoneat every call site to work around a missing config. SettimeZoneinapp.config.tsonce. A per-call convention is a rule every new call has to remember, and the ones that forget are invisible until a viewer in another zone reads a wrong date. - Don't call
Intl.DateTimeFormat().resolvedOptions().timeZonein a component to "fix" SSR. It is the viewer's true zone and therefore the wrong value: the server could not know it, so the server did not render with it, and using it on the client guarantees the mismatch. Let the server decide and publish — that is whattimeZone: 'viewer'does. - Don't pass a locale-formatted string to a machine consumer. Formatted output is presentation — send ISO strings and raw numbers to APIs,
dateTimeattributes and sort keys. - Don't format inside a
.map()by constructingIntlobjects yourself. The hooks memoize per locale; a freshnew Intl.NumberFormat(...)per row is the slow path.