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"   → many

Polish 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` given

Explicit 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. Omit timeZone and the viewer's own zone is used — which is what a multi-timezone app wants. Pass one only when the value genuinely belongs to a fixed zone (a store's opening hours, a scheduled broadcast). Pinning a global zone across the whole app is the anti-pattern this replaces.

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; the default base is Date.now().

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 defaults

useFormatNumber() 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, 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 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.

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 / usePlural is right when the forms are decided in code — a pure helper outside React, a meta({ 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. Use plural / usePlural or ICU in the catalog.
  • Don't hand-roll "X minutes ago". useRelativeTime is one hook, is localized, and handles the sub-second and unit-selection cases that inline versions get wrong.
  • Don't pin a global timeZone / locale for the whole app. The formatters resolve the active locale from the provider; a pin makes every viewer read the app in one user's settings.
  • Don't pass a locale-formatted string to a machine consumer. Formatted output is presentation — send ISO strings and raw numbers to APIs, dateTime attributes and sort keys.
  • Don't format inside a .map() by constructing Intl objects yourself. The hooks memoize per locale; a fresh new Intl.NumberFormat(...) per row is the slow path.