Hooks

useLocale

The reader's resolved locale, time zone, first day of week and numbering system, plus memoised Intl formatters that print the same bytes on the server and after hydration.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/** A moment: a `Date`, epoch milliseconds, or a string `Date.parse` understands (prefer ISO 8601 with an offset). */
export type DateInput = Date | number | string

/** Where the snapshot's locale and time zone actually came from. */
export type LocaleSource = "props" | "environment" | "fallback"

/** Options for `formatCurrency`. `style` is fixed; pass `currency` to override the hook's default for one call. */
export type FormatCurrencyOptions = Omit<Intl.NumberFormatOptions, "style">

export interface UseLocaleOptions {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-locale.json

Prompt

The prompt behind this hook — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript "useLocale" hook (React 19 only; every formatting
primitive comes from the built-in Intl object — no date library, no i18n
runtime, no polyfill, no bundled tzdata).

Contract
- "use client". export function useLocale(options?: UseLocaleOptions):
  UseLocaleResult, plus a default export.
- UseLocaleOptions: locale? (a BCP 47 pin), timeZone? (an IANA pin),
  fallbackLocale? (default "en-US"), fallbackTimeZone? (default "UTC"),
  currency? (ISO 4217, default "USD"), invalidPlaceholder? (default an em
  dash) — what every formatter returns for a value that is not a real instant.
- UseLocaleResult is the snapshot plus the formatters bound to it:
    locale            the tag Intl actually negotiated ("en-GB", not "en_GB")
    timeZone          canonical IANA id every date formatter is bound to
    region            region subtag of the maximized locale, or null
    direction         "ltr" | "rtl"
    firstDayOfWeek    ISO weekday number, 1 = Monday … 7 = Sunday
    numberingSystem   "latn" | "arab" | "deva" | …
    hourCycle         "h11" | "h12" | "h23" | "h24", and hour12 derived from it
    source            "props" | "environment" | "fallback"
    formatDate / formatTime / formatDateTime
                      (value: DateInput, options?: Intl.DateTimeFormatOptions)
    formatNumber      (value: number, options?: Intl.NumberFormatOptions)
    formatCurrency    (value: number, options?: Omit<NumberFormatOptions,"style">)
    formatRelativeTime(value: DateInput, now: DateInput, options?)
  DateInput = Date | number | string.
- The whole result is one useMemo whose dependencies are all primitives, so the
  object and every closure on it keep one identity across renders and are safe
  in a dependency array or handed to a memoized child.

Behavior — the hydration contract (the reason this hook exists)
- Detection NEVER happens during render. It is a useSyncExternalStore snapshot:
    getSnapshot()       a module-level cached string "locale|timeZone"
    getServerSnapshot() always the unresolved sentinel (an empty string)
    subscribe(cb)       one "languagechange" listener on window; when it fires,
                        drop the cached string and call cb. The cleanup removes
                        the listener, so nothing survives unmount.
  The server and the hydrating frame therefore both format with fallbackLocale /
  fallbackTimeZone — identical bytes, no mismatch — and the reader's real
  conventions arrive in the re-render React performs immediately after
  hydration, with `source` flipping from "fallback" to "environment".
  Formatting with the ambient locale during render is the bug being prevented:
  the server's default is the machine's, the client's is the visitor's, and the
  two strings differ byte for byte.
- Cache the snapshot string: React calls getSnapshot on every render and after
  every event, and resolving Intl each time is pure waste. Strings compare by
  value, so returning the same characters is enough to keep React still.
- Detection order: navigator.languages first (what the reader chose), letting
  Intl negotiate it down to a tag it has data for; a bare Intl.DateTimeFormat()
  second (the runtime default, which on a server or an Electron shell is the
  machine's OS locale, not the reader's). Both attempts are wrapped — a single
  malformed entry in navigator.languages throws for the whole list.

Behavior — resolution and refusal
- Every tag goes through Intl's own negotiation: "EN-us" becomes "en-US",
  "en_US" throws RangeError and is refused. Time zones are canonicalized the
  same way ("america/new_york" becomes "America/New_York"; "Mars/Olympus" and
  ids an old tzdata never heard of are refused).
- A REFUSED PIN FALLS TO fallbackLocale / fallbackTimeZone, never to detection.
  A pin that silently became reader-dependent is a worse bug than a visibly
  wrong one, and it would resurrect the SSR mismatch this hook removes.
- `source` reports the least-resolved of the locale and the zone (props <
  environment < fallback): one detected field is enough to make the whole
  snapshot reader-dependent, which is exactly what a consumer needs to know
  before it decides to render locale-sensitive text.
- RangeError is caught for DATA (tags, zone ids, currency codes). A malformed
  OPTIONS object is a programming mistake and is left to throw.

Behavior — the derived facts
- firstDayOfWeek: Intl.Locale.prototype.getWeekInfo().firstDay where it exists,
  the legacy weekInfo accessor on the engines that shipped it as a property,
  and a small region table as the last resort. Normalize 0 to 7 — the spec
  numbers Sunday 7, one engine generation shipped it as 0. Calendars that index
  Sunday as 0 want firstDayOfWeek % 7.
- direction: getTextInfo().direction, the legacy textInfo accessor, or a set of
  right-to-left language subtags.
- hourCycle: resolvedOptions() of a formatter probed with { hour: "numeric" },
  falling back to hour12 when the engine reports no hourCycle. hour12 is then
  derived — "h11" and "h12" are the twelve-hour ones.
- numberingSystem: resolvedOptions() of the plain number formatter.
- All of it is memoised per tag in module-level Maps, next to the canonicalized
  tags and zones, so a table of five hundred rows resolves each one once.

Behavior — the formatters
- One module-level Map holds every Intl instance, keyed
  kind|locale|JSON.stringify(options). Intl formatters are expensive to build
  and stateless once built, so the cache is shared by every component on the
  page; two option objects differing only in key order cost one spare entry,
  never a wrong result.
- Date presets are REPLACED by caller options, not merged: Intl throws a
  TypeError when dateStyle meets an individual field, so a merged
  { dateStyle: "medium" } would break the moment someone asks for
  { month: "short", day: "numeric" }. Number and relative-time options are
  merged, because those option sets have no exclusive combinations.
- timeZone is injected into every date formatter unless the caller passed one,
  so a per-call override ("show this row in the event's zone") stays possible
  without ever falling back to the ambient zone.
- formatCurrency validates the code against /^[A-Z]{3}$/ after trimming and
  upper-casing. A code Intl would reject must be ERASED (currency: undefined),
  not merely paired with style "decimal" — the spec validates the currency
  option whatever the style is, so passing it through still throws.
- formatRelativeTime takes `now` as an ARGUMENT. Nothing in this hook reads a
  clock: Date.now() in render is impure and would drift between the server's
  tick and the client's. Pick the unit with a coarsest-fit ladder (1s, 60s, 1h,
  1d, 7d, the mean Gregorian month 2629746000ms, the mean Gregorian year
  31556952000ms), take the last step the absolute gap still clears, and format
  Math.round(gap / step). Under half a second that rounds to 0, which
  numeric: "auto" renders as "now" — and the same "auto" gives "yesterday"
  instead of "1 day ago" wherever a language has the idiom.
- Any value that is not a finite instant (an unparseable string, a Date built
  from NaN) returns invalidPlaceholder from every formatter instead of letting
  Intl throw "Invalid time value". Strings go through Date.parse, whose
  behaviour outside ISO 8601 is implementation-defined — document that callers
  should pass a Date, epoch milliseconds, or an ISO string with an offset.

Rendering & styling
- The hook renders nothing and owns no DOM, so it has no keyboard map, no ARIA
  role and no motion budget of its own. What it does carry is the two
  attributes consumers must not forget: put `direction` on dir for any element
  holding a formatted value (a bdi element isolates it without disturbing the
  surrounding layout), and put `locale` on lang so screen readers pronounce the
  string with the right voice.
- Consumers style with semantic tokens only — text-muted-foreground for labels,
  text-foreground for values, bg-card and border for the surrounding surface —
  and should reach for tabular-nums on any column of formatted numbers so the
  digits stop jittering between rows.

Customization levers
- fallbackLocale / fallbackTimeZone are the SSR guess. Set them to whatever
  your traffic mostly is (or to the value your middleware already read out of
  Accept-Language / a cookie) and the post-hydration correction becomes a no-op
  for most visitors.
- locale / timeZone pin the snapshot per subtree — an export preview that must
  render in the workspace's zone, a screenshot fixture that must be
  deterministic, a "how does this invoice look in Japan" panel.
- currency is per hook and overridable per call, so a multi-currency table can
  keep one hook and pass the row's code.
- The presets (dateStyle "medium", timeStyle "short", numeric "auto") are
  constants at the top of the file — change them once instead of passing the
  same options object at every call site.
- The relative ladder is a flat ordered array: drop the week step, or add a
  "quarter" tier, without touching the formatting call.
- The last-resort week and RTL tables only run on engines without Intl locale
  info; extend them if you support such an engine and care about a region they
  do not list.
- Adding a formatter (Intl.ListFormat, Intl.DisplayNames, Intl.PluralRules)
  means one more entry in the same cache and one more closure on the result —
  the snapshot, the store and the fallback contract stay untouched.

Concepts

  • SSR-safe locale snapshot — the reader's environment is an external mutable source, so it is read through useSyncExternalStore and never during render. getServerSnapshot always answers "unresolved", which makes the server and the hydrating frame agree on the fallback pair byte for byte; the real locale lands in the re-render right after hydration.
  • Injected instantformatRelativeTime(value, now) takes both ends. A hook that called Date.now() would be impure in render and would drift between the server's tick and the client's, so the caller owns "now" and a demo or a test can freeze it.
  • Refusal beats guessing — a pin the runtime rejects falls to the fallback, not to detection. Silently swapping a deliberate de-DE for the visitor's own locale hides the typo and puts the hydration mismatch straight back.
  • Formatter memoisationIntl instances are costly to construct and stateless once built, so one module-level Map keyed by kind, locale and serialized options serves the whole page; a five-hundred-row table builds one date formatter, not five hundred.
  • Derived week and text info — the first day of the week and the writing direction come from Intl.Locale, with the legacy property accessors and a small region table behind them, so a calendar starts on Monday in Berlin, Sunday in New York and Saturday in Cairo without shipping CLDR.
  • Provenance as a first-class fieldsource reports the least-resolved of the locale and the zone, so a consumer can tell "still on the server guess" from "this is really the reader" without inspecting timers or effects.

On This Page