rule 06 — tier 6

Format numbers and dates through the library, with an explicit locale

toLocaleString() and a bare new Intl.NumberFormat() resolve against whatever locale the runtime happens to have, and a .toString() rendered as JSX text prints the wire format in all of them. Render FormattedNumber / FormattedDate, or call formatNumber / formatCurrency with a locale you passed in.

errorlintformat-values-through-the-library

element usage

Layout is yours. Appearance is the library's.

what this catches

An agent needs `8420` to read as `8.420` and writes `value.toLocaleString()`, because it is one call and it renders correctly on the machine that wrote it. It is the same shortest-path-to-something-that-looks-right reflex that produces a hand-built card, applied to the value inside the card.

why

A bare `toLocaleString()` is not a formatting choice, it is the absence of one. With no locale argument the result comes from the runtime's default — the server's ICU default in Node, the user's browser setting in the client — so the same value renders `8,420` in one place and `8.420` in another with nothing in the code to explain the difference.

In a prerendered app that is a hydration bug, not a cosmetic one. This site sets `ssr: false` with a `prerender()` list, so every page is HTML generated in Node and then hydrated in the browser: the two runs format the same number against two different default locales, React finds text it did not expect, and what it does about it is not something to reason about per-component. The fix is the same in either direction — say which locale you mean.

The library's formatters are where that decision is written down once. `FormattedNumber`, `FormattedCurrency` and `FormattedPercentage` render the value through `Intl.NumberFormat` with a locale from the nearest `I18nProvider` (mount one, or pass `locale`); `FormattedDate` pins both a locale and an IANA time zone, so a date is not silently reinterpreted in the reader's zone. For string contexts where a component cannot go — a chart tick callback, a `valueFormatter`, an aria-label — `formatNumber(value, locale)` and `formatCurrency(value, locale)` take the locale as an argument, which is the point: it cannot be forgotten.

`CalendarDate#toString()` and `Time#toString()` from `@internationalized/date` fail the same way from the other end, and worse. They are not locale-dependent at all — they return ISO 8601, the wire format — so instead of a different spelling in each runtime you get the serialization in every runtime: `2026-06-30` in a Description sitting a few pixels under a trigger whose segments spell the same day `30.6.2026`. GritQL cannot see types, so "a value whose type comes from @internationalized/date" is not a pattern that can be written. The position is. A `.toString()` rendered as JSX *text* is either redundant — React stringifies whatever you hand it — or a serialization leak, and neither is something anyone writes on purpose, so that position, and only that position, is what the check matches.

Two honest limits. `FormattedStorage` is not a byte formatter — it takes gigabytes and rolls up to TB, with no KB, MB or PB and no binary-vs-decimal choice — so hand-rolled byte arithmetic is outside what this rule can redirect you to. And `new Intl.DateTimeFormat().resolvedOptions().timeZone`, the standard way to ask what zone you are in, is a construction like any other and is reported; that one is a suppression with a reason, not a rewrite.

use this instead

Number.toLocaleString()
  • FormattedNumber from @/components/formatted-numberrendering a number in JSX
  • formatNumber from @/components/formatted-numbera string is needed — it takes the locale as its second argument
  • useFormatNumber from @/components/formatted-numbera string is needed inside a component, with the locale read from the I18nProvider
Number.toLocaleString() for money
  • FormattedCurrency from @/components/formatted-numberrendering an amount in JSX
  • formatCurrency from @/components/formatted-numbera string is needed — EUR, always two decimals
Date.toLocaleDateString() / new Intl.DateTimeFormat()
  • FormattedDate from @/components/formatted-datean absolute date or time — it renders a semantic <time> with a pinned locale and time zone

Relative output ("3 days ago") depends on when it is rendered, so pass `now` when the result has to be identical on the server and in the browser.

CalendarDate.toString() / Time.toString() rendered into JSX
  • FormattedDate from @/components/formatted-dateechoing a picker's value back to the reader — the segments above it are localized, so the echo has to be too

A `CalendarDate` has no time of day and a `Time` has no day at all, so formatting either through Intl means choosing both plus a zone. Anchor the value with `toZoned(value, zone)` and pass that same `zone` to FormattedDate: anchoring in the viewer's zone instead puts the echo a day away from the segments for any reader west of the one you format in.

wrong / right

Was real code in src/registry/bar-list.examples.tsx, until it was fixed

don't

<BarList
  data={pages}
  valueFormatter={(value) => value.toLocaleString()}
  className="w-full max-w-md"
/>

do

import { formatNumber } from "@/components/formatted-number"

<BarList
  data={pages}
  valueFormatter={(value) => formatNumber(value, "de-DE")}
  className="w-full max-w-md"
/>

A callback returning a string cannot render a component, which is exactly what formatNumber is for. The locale is now in the code rather than in the environment that happens to run it.

A number rendered straight into JSX

Was real code in src/registry/leaderboard.examples.tsx, until it was fixed

don't

<LeaderboardEnd>{player.score.toLocaleString()}</LeaderboardEnd>

do

import { FormattedNumber } from "@/components/formatted-number"

<LeaderboardEnd>
  <FormattedNumber value={player.score} />
</LeaderboardEnd>

FormattedNumber reads the locale from the nearest I18nProvider, so one decision at the root of the app covers every number under it — and the same markup is produced on the server and in the browser.

A controlled picker echoing its own value

Was real code in src/registry/date-picker.examples.tsx, until it was fixed

don't

<Description>{value ? value.toString() : "No date selected"}</Description>

do

import { toZoned } from "@internationalized/date"
import { FormattedDate } from "@/components/formatted-date"

<Description>
  {value ? (
    <FormattedDate
      date={toZoned(value, DISPLAY_TIME_ZONE).toDate()}
      timeZone={DISPLAY_TIME_ZONE}
      dateStyle="medium"
    />
  ) : (
    "No date selected"
  )}
</Description>

Four sibling examples printed 2026-06-30 under a trigger whose segments spelled 30.6.2026, and lint was silent for the whole life of the rule: toString() is on every object in the language, so it can only be matched by where it sits. Here it sits in a JSX expression child, which is the one position where nobody means it.

how to check this

Add one of these to your project and the rule holds without anyone having to remember it — including the agent writing half the JSX. The exceptions below are already applied, so a documented carve-out will not be reported.

What it will and will not catch: A wrapper of your own is allowed to call Intl: the check skips anything inside a function declaration named format* or use*, which is how a project-level formatter opts out without a suppression. It reads the call, not the locale — `toLocaleString("de-DE")` with an explicit locale is reported too, because the library formatter is still the answer and one place to change it is the reason the rule exists. It matches a construction, so a formatter called without `new` — `Intl.NumberFormat(locale).format(n)` is legal and does the same thing — escapes it entirely; the ripgrep line catches those. `new Intl.DateTimeFormat().resolvedOptions().timeZone` is the known false positive in the other direction; suppress it with `lint/plugin/format-values-through-the-library` and the reason. The `.toString()` half is matched by position and not by type, because GritQL has none: the call has to *be* a JSX expression child — `{value.toString()}`, or a template literal in that same slot — with no JSX attribute and no function boundary between the two. So `key={id.toString()}`, a `valueFormatter` callback, `{rows.map((r) => r.id.toString()).join()}` and a `new URLSearchParams(q).toString()` in a helper are all outside it on purpose, as is `n.toString(16)`, which is a radix conversion rather than a date reaching the DOM. Those are misses, not exceptions: the ripgrep line is deliberately looser than the plugin here and will show you them.

Biome — GritQL plugin

biome

Biome has no built-in rule for this one, so it ships as a GritQL plugin. Save it as ui-lib-rules/format-values-through-the-library.grit and load it from an `overrides` entry — the one in the file's own header, which lists the paths the rule applies to (app/**/*.{tsx,jsx}, src/**/*.{tsx,jsx}) and the paths its documented exceptions carve back out. Not from the top-level `plugins`: a plugin listed there runs against every file you lint, and an override can add one but cannot take one away. If your project keeps its components somewhere else, edit the `includes` to match.

// Format numbers and dates through the library, with an explicit locale
// AUTO-GENERATED from https://ui-lib.quebi.de/api/rules/format-values-through-the-library.json — do not edit by hand.
//
// https://ui-lib.quebi.de/rules/format-values-through-the-library
//
// This file says what the rule is, not where it applies. A plugin listed in
// the top-level `plugins` runs against every file Biome lints, so scope it
// with an `overrides` entry — Biome resolves `includes` against the project
// root, which is the one thing a $filename regex in here could not do:
//
//   {
//     "overrides": [
//       {
//         "includes": [
//           "app/**/*.{tsx,jsx}",
//           "src/**/*.{tsx,jsx}",
//           "!src/components/**",
//           "!components/ui/**"
//         ],
//         "plugins": [
//           "./ui-lib-rules/format-values-through-the-library.grit"
//         ]
//       }
//     ]
//   }

language js;

or {
  `$value.toLocaleDateString($args)`,
  `$value.toLocaleTimeString($args)`,
  `$value.toLocaleString($args)`,
  `new Intl.DateTimeFormat($args)`,
  `new Intl.NumberFormat($args)`,
  `new Intl.RelativeTimeFormat($args)`,
  `$value.toString()` where {
    $value <: within JsxExpressionChild() until or {
      JsxAttributeInitializerClause(),
      JsArrowFunctionExpression(),
      JsFunctionExpression()
    }
  }
} as $call where {
  $call <: not within JsFunctionDeclaration(id = r"^(?:format[A-Z]|use[A-Z]).*"),
  register_diagnostic(
    span = $call,
    message = "This puts a value on the page without the library formatter: a toLocale* call or an Intl construction formats against whatever locale the runtime has, which differs between the prerender and the browser, and a .toString() rendered as JSX text prints the wire format in both. Render <FormattedNumber>, <FormattedCurrency> or <FormattedDate> from @/components/formatted-number and @/components/formatted-date, or call formatNumber(value, locale) / formatCurrency(value, locale) where a string is needed. See https://ui-lib.quebi.de/rules/format-values-through-the-library",
    severity = "error"
  )
}

ripgrep — no setup at all

ripgrep

Finds candidates for review in any repo, linter or not. Coarser than the Biome check: it reads lines, not syntax, so expect false positives and treat a clean run as weaker evidence than a clean lint run.

# format-values-through-the-library — candidates for review
rg -n -g '*.{jsx,tsx}' \
  -g '!src/components/**' \
  -g '!components/ui/**' \
  "\\.toLocale(String|DateString|TimeString)\\(|new Intl\\.(NumberFormat|DateTimeFormat|RelativeTimeFormat)\\(|\\{.*\\.toString\\(\\)"

Claiming an exception that is not a path

biome

One exception on this rule is a judgement call, so it cannot be a path. Biome's suppression syntax has a slot for the reason — fill it, because that note is what makes the carve-out reviewable instead of invisible. The rule is named by its plugin's file name, format-values-through-the-library.grit — a suppression that names this rule quiets this rule and no other plugin on the same line, and if the file is renamed Biome reports the comment as having no effect rather than guessing. `lint/plugin` on its own is valid too and quiets every plugin rule on the node at once; name the rule instead.

// biome-ignore lint/plugin/format-values-through-the-library: Asking the platform what it resolved, rather than formatting with it — `new Intl.DateTimeFormat().resolvedOptions().timeZone` is the standard way to read the current time zone and has no library equivalent.

// Between JSX children, where a comment has to be an expression, the same
// line is written {/* biome-ignore … */}.

Enforcing more than this one rule? Take the whole config instead of collecting snippets.

exceptions

Carve-outs are part of the rule, not a way around it. Each one is already an ignore glob in the checks above, so the cases listed here need no disable comment — and a case that is not listed is one to argue for, not to silence.

Your copy of the ui-lib component source (components/ui/**)
The formatters this rule points at are implemented there: FormattedNumber, FormattedDate and the Calendar's month/year labels format through Intl themselves — via the cached constructors in `@/lib/intl`, which build one formatter per (locale, options) pair instead of one per rendered value — which is what makes the rest of the codebase able not to.
Asking the platform what it resolved, rather than formatting with it
`new Intl.DateTimeFormat().resolvedOptions().timeZone` is the standard way to read the current time zone and has no library equivalent. Written with `new` it matches the pattern, so it needs an inline suppression saying that is what it is — `// biome-ignore lint/plugin/format-values-through-the-library: <reason>` — — the one false positive this check is known to produce. (Written without `new`, which is how the idiom is usually spelled, it is not matched at all.)

scope and enforcement

Applies to
  • app/**/*.{tsx,jsx}
  • src/**/*.{tsx,jsx}
Enforced by
lint