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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-locale.jsonPrompt
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
useSyncExternalStoreand never during render.getServerSnapshotalways 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 instant —
formatRelativeTime(value, now)takes both ends. A hook that calledDate.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-DEfor the visitor's own locale hides the typo and puts the hydration mismatch straight back. - Formatter memoisation —
Intlinstances are costly to construct and stateless once built, so one module-levelMapkeyed 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 field —
sourcereports 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.
useDragDropFiles
File drag and drop as props to spread — a dragenter/dragleave depth counter nested children cannot flicker, an honest drag-time validity preview, dropped folders walked through the FileSystem entry API, and every refusal itemised with a typed reason.
useMediaRecorder
MediaRecorder as a hook — start / pause / resume / stop, a live duration that excludes pauses, a container negotiated against what the browser really supports, permission and unsupported as first-class states, and every track released on unmount.