@uniflowed/hooks/browser
type
BrowserNavigator
export type BrowserNavigator = {
readonly onLine?: boolean,
readonly userAgent?: string,
readonly clipboard?: ?{
readonly readText: () => Promise<string>,
readonly writeText: (text: string) => Promise<void>,
...
},
readonly geolocation?: ?Geolocation,
readonly permissions?: ?{
readonly query: (descriptor: { readonly name: string, ... }) => Promise<PermissionStatus>,
...
},
readonly connection?: ?NetworkConnection,
...
};
The part of a navigator this package reads.
Not a declaration of Navigator — Flow ships one of those. This is the list of what these hooks actually touch, which is short enough to be worth writing down and is the reason none of them needs an any: everything below browserWindow() is checked against this.
Every field is optional because a hosted document is not required to carry the whole of a browser. navigator.geolocation is null under happy-dom and absent under a bare Node global, and navigator.connection exists nowhere but Chromium.
type
BrowserLocation
export type BrowserLocation = {
readonly href: string,
readonly hash: string,
...
};
The part of location this module reads.
href and the fragment, and nothing else. The path and the query are on the request, so @uniflowed/router has already resolved them and a second reading of them here would be a second answer to a settled question — see useHash.
type
BrowserHistory
export type BrowserHistory = {
readonly state: mixed,
readonly pushState: (state: mixed, unused: string, url: string) => void,
readonly replaceState: (state: mixed, unused: string, url: string) => void,
...
};
The part of history this module writes through.
state is read so that a fragment written from here hands back whatever a router put there, rather than clearing it. The second parameter of both methods has been ignored by every browser since the API shipped, and is typed rather than omitted because it is positional.
type
BrowserNavigation
export type BrowserNavigation = {
readonly addEventListener: (type: "currententrychange", listener: () => mixed) => void,
readonly removeEventListener: (type: "currententrychange", listener: () => mixed) => void,
...
};
The part of the Navigation API this module listens to.
One event, and deliberately only one. currententrychange fires after the current history entry has changed *for any reason* — a link, the back button, and the two calls that fire nothing else, history.pushState and history.replaceState. It is the only thing the platform offers that hears a fragment written by code other than the writer, which is what makes useHash able to see @uniflowed/router's own navigation.
navigate and navigateerror are not here: intercepting a navigation is the router's business, and a hook that reads the fragment has no opinion about whether one should happen.
Optional on [BrowserWindow], because this is an addition rather than the base — see useHash for which browsers have it and what the others get.
type
NetworkConnection
export type NetworkConnection = {
readonly downlink?: number,
readonly effectiveType?: string,
readonly saveData?: boolean,
readonly addEventListener?: (type: string, listener: () => mixed) => void,
readonly removeEventListener?: (type: string, listener: () => mixed) => void,
...
};
The Network Information object, which only Chromium has.
type
BrowserWindow
export type BrowserWindow = {
readonly document: Document,
readonly navigator: BrowserNavigator,
readonly location?: ?BrowserLocation,
readonly history?: ?BrowserHistory,
readonly navigation?: ?BrowserNavigation,
readonly localStorage?: ?Storage,
readonly sessionStorage?: ?Storage,
readonly innerWidth: number,
readonly innerHeight: number,
readonly scrollX: number,
readonly scrollY: number,
readonly matchMedia?: (query: string) => MediaQueryList,
readonly getComputedStyle?: (element: Element) => CSSStyleDeclaration,
readonly requestAnimationFrame?: (callback: (time: number) => mixed) => AnimationFrameID,
readonly cancelAnimationFrame?: (handle: AnimationFrameID) => void,
// Two overloads, the way Flow's own `EventTarget` is declared: a `storage`
// listener is handed a `StorageEvent` and needs its `key`, and narrowing an
// `Event` down to one at runtime would mean an `instanceof StorageEvent`
// against a name that is not defined in every host a uf test runs in.
readonly addEventListener: ((
type: "storage",
listener: (event: StorageEvent) => mixed,
options?: EventListenerOptionsOrUseCapture,
) => void) &
((
type: string,
listener: (event: Event) => mixed,
options?: EventListenerOptionsOrUseCapture,
) => void),
readonly removeEventListener: ((
type: "storage",
listener: (event: StorageEvent) => mixed,
options?: EventListenerOptionsOrUseCapture,
) => void) &
((
type: string,
listener: (event: Event) => mixed,
options?: EventListenerOptionsOrUseCapture,
) => void),
readonly ResizeObserver?: Class<ResizeObserver>,
readonly IntersectionObserver?: Class<IntersectionObserver>,
readonly MutationObserver?: Class<MutationObserver>,
...
};
The part of a window this package reads.
Same idea as [BrowserNavigator], and the same reason: naming what is touched turns every read in the package into a checked one. The observer constructors are optional because a browser old enough to lack one is a browser a hook here has to keep working in — it degrades to reporting nothing rather than throwing during a render.
function
browserWindow
export function browserWindow(): BrowserWindow | null { ... }
The window these hooks listen to, or null where there is no browser.
In a browser globalThis *is* the window, so globalThis.addEventListener looks correct. It is not correct anywhere a document has been installed onto another host's global — which is every uf test process, where globalThis is Node's and has no addEventListener at all. Ask the document's own window for its methods and both cases work.
Exported because it is the first question every hook in this package asks, and an application writing its own prerender-safe hook has to ask it too. The single ?? globalThis is this package's only unchecked step: window is any in Flow's own library definition, and this is where that stops.
hook
useSupported
export hook useSupported(probe: () => boolean): boolean { ... }
Whether a capability the browser may not have is there.
The naive version — typeof window.BroadcastChannel === "function" in the render — is a hydration mismatch waiting to happen: the server says one thing, the client's first render says another, and React reports it against whatever markup happened to differ. Asked through useSyncExternalStore, the server's answer is false, the hydrating render agrees with it, and React re-renders with the truth immediately afterwards.
probe is called during render, so it must only look — never install, never request. It is passed straight through rather than stabilised: a snapshot is a question the render is asking now, and a stable callback's body is installed in an insertion effect that has not run yet, so it would answer from the render before.
hook
export hook useMediaQuery(query: string, serverValue: boolean = false): boolean { ... }
Whether a media query matches.
serverValue is what a prerender should assume, and it has no honest default — a page that hides a sidebar under 48rem wants false on the server, and one that renders a mobile menu wants true. So the caller says.
hook
usePreferredColorScheme
export hook usePreferredColorScheme(serverValue: "light" | "dark" = "light"): "light" | "dark" { ... }
The reader's colour-scheme preference.
hook
usePrefersReducedMotion
export hook usePrefersReducedMotion(serverValue: boolean = false): boolean { ... }
Whether the reader has asked for less motion.
hook
useOnline
export hook useOnline(serverValue: boolean = true): boolean { ... }
Whether the browser thinks it is online.
hook
useDocumentVisible
export hook useDocumentVisible(serverValue: boolean = true): boolean { ... }
Whether the document is the one the reader is looking at.
hook
useHash
export hook useHash(): [
string,
(next: string, options?: {| readonly replace?: boolean |}) => void,
] { ... }
The fragment in the address bar, and a way to change it.
The one part of the URL that needs a hook. A request carries the path and the query, so a server render already knows both and @uniflowed/router's useRoute has resolved them; the fragment is never sent — the browser strips it before the request goes out — so there is no value for a server to know and no caller who could supply a better one. That is why this takes no serverValue where useMediaQuery and useOnline do: "" is not a default chosen for want of a better one, it is the only honest answer, and the client's hydrating render reports it too before re-rendering with the truth.
Returned without the #, and percent-decoded, so it compares directly against the id a section was given.
Writing does not scroll, and that is deliberate. Moving to a section and recording which tab is open are two intentions, and the platform couples them only because assigning location.hash is the old way to do both at once — so a tab strip that writes billing would jump the page. Call element.scrollIntoView() where scrolling is what was wanted.
replace overwrites the current history entry instead of adding one, which is what a tab strip wants: eleven tab clicks should not be eleven presses of the back button.
# What it hears, and where
history.pushState and history.replaceState fire no event of any kind — not hashchange, not popstate — so what a hook can see depends on where the write came from and on what the browser has:
| The write | Everywhere | Without the Navigation API | | --- | --- | --- | | the reader: an anchor, the address bar, back and forward | seen | seen | | this hook's own setter | seen | seen | | pushState from other code — @uniflowed/router's navigation | seen | **not seen** |
The first two are hashchange, popstate and the module's own registry. The third is currententrychange, which fires after the current history entry changes for any reason at all, and which is the only thing the platform offers that hears a write the writer did not announce.
"Without the Navigation API" is now a narrow set: Chrome and Edge have had it since 102 (2022), Safari since 26.2 and Firefox since 147 — but a reader on an older Safari or Firefox is a reader this column describes, and there the registry is still the whole answer. A router navigation that changes only the fragment leaves such a page showing the section it was on.
The fragment is deliberately not on RouteInfo — useRoute() cannot answer this question and should not learn to. A RouteInfo is what a *request* resolved to, and the browser strips the fragment before the request goes out, so a field for it would be one the server could never fill and the two renders would disagree about. This hook is the one answer, and currententrychange is what makes it a complete one on the browsers that have it rather than a second reading of a value the router also holds.
type
export type ScrollOffset = {| readonly x: number, readonly y: number |};
How far something has been scrolled.
Defined here rather than in dom.js because dom.js imports this module and not the other way round; useScroll over an element uses the same shape, and one name for one thing is worth the arrow.
type
Size
export type Size = {| readonly width: number, readonly height: number |};
How big something is. Shared with useElementSize for the same reason.
hook
useWindowSize
export hook useWindowSize(serverValue?: Size): Size { ... }
The size of the viewport.
hook
export hook useWindowScroll(serverValue?: ScrollOffset): ScrollOffset { ... }
How far the page has been scrolled.
The same packed-string snapshot as useWindowSize, for the same reason, and a passive listener because a scroll handler that could call preventDefault blocks scrolling on a touch screen until it has run.
hook
export hook useScrollLock(locked: boolean): void { ... }
Hold the page still while locked.
A layout effect, so the page is frozen before the frame in which the dialog that asked for it appears — an ordinary effect lets one frame of scrolling through, which reads as a jump.
On a server this does nothing at all: effects do not run during a prerender, so a locked dialog rendered into HTML leaves the markup alone.
type
EffectiveConnectionType
export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
How good the connection is, as the browser grades it.
type
NetworkMeasurement
export type NetworkMeasurement = {|
/** Estimated bandwidth in megabits per second, where the browser reports it. */
readonly downlink: number | null,
readonly effectiveType: EffectiveConnectionType | null,
/** Whether the reader has asked for less data to be used. */
readonly saveData: boolean,
|};
What Network Information measured, where there is such a thing.
Its own type rather than three more fields on [Network], because the three of them arrive together or not at all: they come from navigator.connection, which is Chromium's alone. A server, and every other browser, has no object to read them off.
type
Network
export type Network = {|
readonly online: boolean,
readonly measured: NetworkMeasurement | null,
|};
What the browser will say about the connection.
measured used to be three flat fields and a supported boolean beside them, which is the shape this package now refuses: a caller had to read one field to learn whether three others meant anything, and downlink: null said both "this browser does not measure bandwidth" and "it does, and has not decided yet". A null here says one thing — nothing on this side can answer — and the fields that would have been guesses are not reachable to be read.
hook
useNetwork
export hook useNetwork(serverValue: boolean = true): Network { ... }
What the browser will say about the connection.
Only Chromium implements Network Information, so a browser that does not reports measured: null rather than three fields that are indistinguishable from a slow connection. online stays flat and is answered everywhere — it is the field almost every caller wants, and burying it behind a narrowing would have made the common case pay for the rare one.
The snapshot is a packed string for the reason useWindowSize gives: an object rebuilt on every check never compares equal, and useSyncExternalStore would re-render forever.
type
Geoposition
export type Geoposition = {|
readonly latitude: number,
readonly longitude: number,
/** Radius of a 95% confidence circle, in metres. */
readonly accuracy: number,
readonly timestamp: number,
|};
Where the reader is, to the accuracy the browser was willing to give.
type
GeolocationReading
export type GeolocationReading =
/**
* Not watching, because the caller passed `enabled: false`. Reported before
* `"unsupported"` is even considered, so it is the same on both sides of a
* hydration.
*/
| {| readonly status: "idle" |}
/** No geolocation object here at all: a server render, or a browser without one. */
| {| readonly status: "unsupported" |}
/** Watching. The reader has been asked and has not answered yet. */
| {| readonly status: "pending" |}
/**
* A fix. `error` is the failure of a *later* reading, and its presence means
* the position beside it is the last good one rather than the current one.
*/
| {| readonly status: "located", readonly position: Geoposition, readonly error: Error | null |}
/** Refused, unavailable or timed out, with no earlier fix to fall back on. */
| {| readonly status: "failed", readonly error: Error |};
What useGeolocation knows so far.
A union rather than a record of nullable fields, and the difference is the whole point of the type. The record it replaced — {| position: Geoposition | null, error: Error | null, supported: boolean |} — could represent eight states, of which four could never happen, and it asked every caller to work out from three fields which of the four real ones they were in. position == null meant "no browser", "not asked", "asked and refused" and "waiting for the reader to decide", and a page that wanted to say something different for each had to reconstruct the distinction the hook had thrown away.
Five states, each of which a page does something different about, and Flow refuses to read a field the state does not have.
hook
useGeolocation
export hook useGeolocation(options?: {|
readonly enabled?: boolean,
readonly highAccuracy?: boolean,
readonly maximumAge?: number,
readonly timeout?: number,
|}): GeolocationReading { ... }
Watch where the reader is.
An effect rather than a useSyncExternalStore, and the difference is not stylistic: there is no snapshot to read. The browser has no "current position" property to ask — the first value arrives in a callback, after a permission prompt the reader may take a minute to answer or never answer at all. So there is nothing but "unsupported" to report during a prerender and in the client's first render, which is also what makes it hydration-safe: the server writes the markup for a page that does not know where anybody is, and the hydrating render agrees with it.
Turning enabled off after a fix reports "idle" rather than keeping the position, because a reading nobody is watching is a reading nobody should be shown. A caller who wants the last one to stay on screen holds it.
Mounting this asks the reader for permission. Mount it on the page that needs a position, not at the top of an application.
type
PermissionName
export type PermissionName =
| "geolocation"
| "notifications"
| "camera"
| "microphone"
| "clipboard-read"
| "clipboard-write"
| "persistent-storage"
| "push"
| "midi";
A permission this hook knows how to ask about.
type
PermissionAnswer
export type PermissionAnswer = "granted" | "denied" | "prompt" | "unknown";
What the browser says about a permission.
"unknown" is one value for four situations that a caller treats the same way — no Permissions API, a name this browser does not recognise, an answer that has not arrived yet, and a server render. Splitting them would make every caller write the same four-armed match to reach the same conclusion.
hook
usePermission
export hook usePermission(name: PermissionName): PermissionAnswer { ... }
Whether the reader has granted a permission, without asking for it.
Querying is not prompting: this reports the current state and follows it if the reader changes their mind in browser settings. Asking for the permission is the API's own job — getUserMedia, watchPosition — and doing it from here would make a hook that reads have a side effect nobody asked for.
An effect rather than a store, for the reason useGeolocation gives: the answer is a promise, so there is nothing to read synchronously.
@uniflowed/hooks/dom
type
Ref
export type Ref<T> = { current: T | null };
A ref object these hooks read: what useRef and useElementRef return.
type
ListenerTarget
export type ListenerTarget<T> = Ref<T> | (() => T | null) | null;
What a listener can be attached to.
A ref, or a function that finds the target. The function form is what covers the window and the document, which no ref points at.
type
ListenerOptions
export type ListenerOptions = {|
readonly capture?: boolean,
readonly passive?: boolean,
readonly once?: boolean,
|};
The part of addEventListener's options a hook here passes on.
signal is deliberately absent: these hooks remove their own listener in the effect's cleanup, and a second, independent way to remove it would be a second thing that can be wrong.
hook
useEventListener
export hook useEventListener<TTarget extends EventTarget>(
target: ListenerTarget<TTarget>,
name: string,
handler: (event: Event) => mixed,
options?: ListenerOptions,
): void { ... }
Listen to an event on a target, cleaning up after itself.
The handler is stabilised, so passing an inline arrow does not tear the listener down and set it up again on every render — which is the bug this hook exists to prevent and the reason it does not take a dependency array.
The target is read once, when the listener is attached. Passing a ref and later pointing it at a different element does not move the listener; a component whose target changes should let the element unmount and mount again, which is what React does anyway when the element is conditional.
hook
useClickOutside
export hook useClickOutside(ref: Ref<HTMLElement>, handler: (event: Event) => mixed): void { ... }
Call handler when a press lands outside ref.
pointerdown rather than click, because a menu that closes on click stays open for the whole press — and because a click whose press started inside the menu and ended outside it should not close it.
hook
useHover
export hook useHover(ref: Ref<HTMLElement>): boolean { ... }
Whether the pointer is over the element.
hook
useFocusWithin
export hook useFocusWithin(ref: Ref<HTMLElement>): boolean { ... }
Whether focus is inside the element.
hook
useLongPress
export hook useLongPress(
ref: Ref<HTMLElement>,
handler: (event: Event) => mixed,
options?: {| readonly delay?: number, readonly moveThreshold?: number |},
): void { ... }
Call handler when a press on the element lasts.
Cancelled by letting go, by the pointer leaving, and by the pointer moving further than moveThreshold — a press that turns into a scroll or a drag is not a long press, and a version that only watched for pointerup fires a context menu in the middle of a fling.
The handler is called once per press, while the finger is still down, which is when a long press is supposed to be felt.
hook
useElementSize
export hook useElementSize(ref: Ref<HTMLElement>): Size { ... }
The element's size, as the browser measures it.
A ResizeObserver rather than a window resize listener, because an element changes size when its content changes, when a sibling grows, and when a container query fires — none of which resizes the window.
hook
useIntersecting
export hook useIntersecting(
ref: Ref<HTMLElement>,
options?: {| readonly rootMargin?: string, readonly threshold?: number |},
): boolean { ... }
Whether the element is in the viewport.
type
MutationOptions
export type MutationOptions = {|
/** Children added or removed. The default, unless another kind is asked for. */
readonly childList?: boolean,
/** Descendants as well as the element itself. */
readonly subtree?: boolean,
readonly attributes?: boolean,
readonly characterData?: boolean,
/** Only these attributes, where `attributes` is on. */
readonly attributeFilter?: $ReadOnlyArray<string>,
|};
What part of the tree under the element to watch.
hook
useMutationObserver
export hook useMutationObserver(
ref: Ref<HTMLElement>,
handler: (records: $ReadOnlyArray<MutationRecord>) => mixed,
options?: MutationOptions,
): void { ... }
Call handler when the element's markup changes.
The last resort of the three observers, and worth saying so: a size is a ResizeObserver, a position is an IntersectionObserver, and this is for the case where something outside React edits the DOM — a third-party widget, a browser extension, a contenteditable. Watching a tree React owns in order to learn about React's own updates is a mistake this hook cannot prevent but should not encourage.
attributeFilter is compared by its contents rather than its identity, so an array written inline in the call does not re-observe on every render.
hook
export hook useScroll(ref: Ref<HTMLElement>): ScrollOffset { ... }
How far the element has been scrolled.
The element's own offset, not the page's — useWindowScroll is the page's, and lives in browser.js because there is only one page.
A passive listener, because a scroll handler that could call preventDefault blocks scrolling on a touch screen until it has run; and a layout effect for the first reading, because a container restored to a saved offset should not report zero for one frame.
hook
useElementRef
export hook useElementRef<T extends HTMLElement>(): Ref<T> { ... }
A ref for one of the hooks above, typed for the element you will attach it to.
hook
useElementState
export hook useElementState<T extends HTMLElement>(): [T | null, (node: T | null) => void] { ... }
The element a ref points at, as a value a render can depend on.
A ref is not state: React does not re-render when current changes, and reading ref.current during a render is a rule violation because the render that reads it may be one React throws away. A component that has to *render* something derived from its own element — a measurement, a portal target — needs the element as state, which is what a callback ref gives.
The returned function is stable, so passing it as ref={setNode} does not detach and reattach on every render.
@uniflowed/hooks/render
type
RenderEnvelope
export type RenderEnvelope = {
/** The instant the render was anchored to, in epoch milliseconds. */
readonly at: number,
/** The IANA zone the server was in. Not the reader's — see `Time`. */
readonly timeZone: string,
/** The seed both sides replay the same numbers from. */
readonly seed: string,
};
What a render fixes, and what travels to the client.
variable
export const RENDER_META: string = "uf:render";
The <meta> name the envelope is written under, and read back out of.
A name rather than an id because that is what a <meta> is addressed by: document.querySelector('meta[name=…]') is the read, and React's own hoisting treats the name/content pair as the element's identity.
component
RenderProvider
export component RenderProvider(
at?: number,
timeZone?: string,
seed?: string,
children: React.Node,
) { ... }
Fix this render's instant, zone and seed, and hand them to the tree.
A @uniflowed/router application already has one: routerView renders this above everything, so useRenderedAt and useRandom agree across hydration without the application saying anything. Rendering one by hand is for the cases that need different values, and it is a *replacement* rather than an addition — a nested provider inherits the envelope above it, overrides only the fields it was given, and writes no second carrier. See ubugeeei-prod/uf#559.
Every argument is optional and the defaults are the whole point: a server decides, the markup carries what it decided, and the browser reads it back before its first render, so neither side has to be told which one it is.
at and seed are there for the two cases that are not that. A test passes them to get a page that renders the same bytes every time; an application whose instant comes from somewhere better — a request header, a loader — passes that instead. Both have to be values the *browser* arrives at too: they are not carried, because what is carried is the outermost envelope, and a value only one side can compute is the mismatch this module exists to remove.
The carrier is a <meta>, which is what lets this be rendered above a root layout that owns <html>: React hoists it into the head of the document either way. The header of this file has the whole argument.
hook
useRenderEnvelope
export hook useRenderEnvelope(): RenderEnvelope | null { ... }
What this render was anchored to, or null outside a RenderProvider.
Null rather than a fabricated envelope, because "nobody fixed these values" is a fact the hooks in timing.js act on: without a provider they read the clock, which is the behaviour they have always had.
hook
useRenderedAt
export hook useRenderedAt(): Instant { ... }
The instant this page was rendered at, as a Temporal.Instant.
Constant for the life of the render, on both sides, which is what makes it safe to put in the markup. It is not "now" and does not become "now": a page left open for an hour still reports the instant it was rendered at, and a label that has to stay true while the reader looks at it is useTimeAgo.
Falls back to the clock outside a provider, which is right for a page that is only ever rendered once — and is a hydration mismatch on one that is prerendered, which is what the provider is for.
hook
useRenderTimeZone
export hook useRenderTimeZone(): string { ... }
The zone the render was made in.
The server's, not the reader's, and the distinction is the second half of the hydration problem rather than a detail: markup formatted in the reader's zone cannot match markup formatted in the server's, so a component renders this one and localises after hydration. @uniflowed/web's Time is that component.
hook
useRandom
export hook useRandom(label: string): Random { ... }
A stream of random numbers that both renders produce identically.
label names the stream, and naming it is what makes it independent of every other one: two components that ask for "featured" and "sidebar" get the same numbers whatever order they render in, and whatever suspends between them. Sharing one stream would make each component's numbers depend on how many the components above it happened to draw — stable in a synchronous render, and not stable once a boundary resolves at a different moment on the two sides.
The stream is stateful, so a component that draws from it during render draws different numbers on a re-render. Draw into a const keyed by what the numbers are for — which the React Compiler memoizes — or in an event, and never twice in the body of a component React may render twice.
Outside a provider the seed is a constant rather than the host's, which looks like the wrong default and is the right one: two renders with no envelope between them still have to agree, and a constant is the only seed both of them can arrive at. What it costs is that every such page shuffles the same way, which is a reason to render a provider rather than a reason to be unpredictable here.
hook
useShuffled
export hook useShuffled<T>(items: $ReadOnlyArray<T>, label: string): Array<T> { ... }
items, shuffled the same way on both sides of a hydration.
The shuffle is memoized over the seed, the label and the items — by the React Compiler, which is where uf's memoization comes from — so it is one shuffle rather than one per render. That matters for more than speed: a fresh draw on every render would reorder the list under the reader every time anything else on the page changed.
@uniflowed/hooks/state
type
UseToggleReturn
export type UseToggleReturn = {|
readonly on: boolean,
readonly toggle: () => void,
readonly set: (value: boolean) => void,
|};
A boolean and the three things a caller ever does to one.
hook
useToggle
export hook useToggle(initial: boolean = false): UseToggleReturn { ... }
A boolean with the three things a caller ever does to one.
type
UseCounterReturn
export type UseCounterReturn = {|
readonly count: number,
readonly increment: (by?: number) => void,
readonly decrement: (by?: number) => void,
readonly set: (value: number) => void,
readonly reset: () => void,
|};
A number and the operations that suit one.
hook
useCounter
export hook useCounter(
initial: number = 0,
bounds?: {| readonly min?: number, readonly max?: number |},
): UseCounterReturn { ... }
A number, optionally clamped.
type
UseListReturn
export type UseListReturn<T> = {|
readonly items: $ReadOnlyArray<T>,
readonly set: (items: $ReadOnlyArray<T>) => void,
readonly push: (item: T) => void,
readonly insertAt: (index: number, item: T) => void,
readonly replaceAt: (index: number, item: T) => void,
readonly removeAt: (index: number) => void,
readonly move: (from: number, to: number) => void,
readonly clear: () => void,
|};
A list and the edits anyone makes to one.
hook
useList
export hook useList<T>(initial: $ReadOnlyArray<T> = []): UseListReturn<T> { ... }
A list, with the six edits anyone ever makes to one.
An index outside the list is not an error and not a throw: it leaves the list alone and returns the same array, so a row removed twice by a double-clicked button is removed once. @uniflowed/form's useFieldArray is the version of this for form rows, and knows about keys, errors and dirty flags; this one is for a list that is only a list.
type
UseSetReturn
export type UseSetReturn<T> = {|
readonly items: $ReadOnlySet<T>,
readonly has: (item: T) => boolean,
readonly add: (item: T) => void,
readonly remove: (item: T) => void,
readonly toggle: (item: T) => void,
readonly clear: () => void,
readonly set: (items: Iterable<T>) => void,
|};
A set of members, and the questions asked of one.
hook
useSet
export hook useSet<T>(initial?: Iterable<T>): UseSetReturn<T> { ... }
A set, which is what a multi-select or a list of expanded rows actually is.
The Set is replaced rather than mutated on every change, because a Set edited in place is the same object and React would not re-render — the bug people meet the first time they put a collection in useState.
type
UseCycleReturn
export type UseCycleReturn<T> = {|
/** The value at the current position, or `null` when the list is empty. */
readonly value: T | null,
readonly index: number,
readonly next: () => void,
readonly previous: () => void,
readonly go: (index: number) => void,
|};
A position in a list that wraps.
hook
useCycle
export hook useCycle<T>(values: $ReadOnlyArray<T>, initialIndex: number = 0): UseCycleReturn<T> { ... }
Step through a list, wrapping at both ends.
A theme switcher, a carousel, a sort order that cycles. The counter behind this is unbounded and the position is worked out from it on each render, so values may change length between renders without the position becoming invalid — and values is deliberately not a dependency of anything, so writing the list inline in the call is free.
value is T | null rather than T because an empty list has no current value. Flow's array access would happily have said T and handed back an undefined at runtime; this package does not claim what it cannot show.
type
UseUndoableReturn
export type UseUndoableReturn<T> = {|
readonly value: T,
readonly set: (next: T) => void,
readonly undo: () => void,
readonly redo: () => void,
readonly canUndo: boolean,
readonly canRedo: boolean,
/** Keep the current value, forget how it got here. */
readonly clear: () => void,
/** Back to the value the hook started with, history and all. */
readonly reset: () => void,
|};
A value with the history behind and ahead of it.
hook
useUndoable
export hook useUndoable<T>(
initial: T,
options?: {| readonly limit?: number |},
): UseUndoableReturn<T> { ... }
A value that can be undone and redone.
One useState holding all three parts, not three: past, present and future change together, and three separate states would be three renders and a window in which they disagree.
set clears the future, which is what every editor does — typing after an undo abandons what was undone. limit bounds the past so that a long editing session does not hold every version of a large value alive; the oldest entries are dropped, and canUndo stops being true when they run out.
hook
useStorage
export hook useStorage<T>(
key: string,
initial: T,
options?: {| readonly session?: boolean |},
): [T, (value: T) => void] { ... }
State kept in localStorage, or in sessionStorage.
initial is what a prerender uses and what an unset or unreadable key falls back to, so the first paint is stated rather than accidental. A value that will not parse is treated as absent rather than thrown: storage is shared with older versions of the same application, and refusing to start because of a stale key would be worse than starting fresh.
@uniflowed/hooks/timing
hook
useInterval
export hook useInterval(body: () => mixed, millis: number | null): void { ... }
Call body every millis, or not at all when millis is null.
Null rather than a separate enabled flag because "no interval" and "an interval of nothing" are the same thing, and one argument cannot disagree with itself.
hook
useTimeout
export hook useTimeout(body: () => mixed, millis: number | null): void { ... }
Call body once after millis, or not at all when millis is null.
hook
useDebouncedValue
export hook useDebouncedValue<T>(value: T, millis: number): T { ... }
value, but only after it has stopped changing for millis.
The classic use is a search box: the query updates on every keystroke and the request should not.
hook
useThrottledCallback
export hook useThrottledCallback<TArgs extends $ReadOnlyArray<mixed>>(
body: (...args: TArgs) => mixed,
millis: number,
): (...args: TArgs) => void { ... }
A callback that runs at most once per millis.
Leading edge: the first call goes through immediately and later ones inside the window are dropped, which is what a scroll or resize handler wants — the trailing-edge version would make the first paint late.
hook
useDebouncedCallback
export hook useDebouncedCallback<TArgs extends $ReadOnlyArray<mixed>>(
body: (...args: TArgs) => mixed,
millis: number,
): (...args: TArgs) => void { ... }
A callback that runs millis after the last time it was asked to.
Trailing edge, and it cancels itself at unmount — the version people write calls setState on a component that is gone.
hook
useAnimationFrame
export hook useAnimationFrame(
body: (frame: {| readonly delta: number, readonly time: number |}) => mixed,
active: boolean = true,
): void { ... }
Run body before every frame the browser paints, while active.
delta is the milliseconds since the previous frame and is zero on the first, which is what an animation integrates against: a frame that took 32ms because the tab was busy has to move twice as far as one that took 16ms, and a hand-written loop that assumes sixty a second runs at half speed on a hundred-and-twenty-hertz display.
Nothing runs before hydration: there is no frame to paint during a prerender, and the effect that would ask for one does not run there.
hook
useIdle
export hook useIdle(
millis: number = 60_000,
options?: {| readonly events?: $ReadOnlyArray<string> |},
): boolean { ... }
Whether the reader has stopped doing anything for millis.
false on a server and on the first client render, which is the answer that cannot be wrong: nobody is idle before the page exists, and starting at true would flash whatever the page shows an idle reader.
The listeners are passive and on the window rather than on any element, so this costs nothing on a touch screen and sees activity anywhere on the page.
hook
useNow
export hook useNow(millis: number | null = 1000, serverValue: Date | null = null): Date { ... }
The current time, re-read every millis.
The clock is read in the initial state rather than in an effect, so a client-only page has the right time on its first paint instead of a frame of something else. That read is the one impure thing in this package, and it is bounded: it happens once, the value is never re-read during a render, and a render React throws away is replaced by another whose clock is just as valid.
On a prerendered page the two renders are at two different instants, so the first one on each side has to be the *same* instant or React reports a mismatch. Under a RenderProvider that happens by itself — the anchor the server fixed travels in the markup, both sides start from it, and the real time arrives with the first effect. serverValue is the same choice made by hand, for a caller who has the instant from somewhere else and for a tree with no provider above it; it wins over the anchor when both are there, because an argument at the call site is a decision and a context is a default.
A Date rather than a Temporal.Instant, and deliberately: this value's consumers subtract it from another one to decide when to run again, which is millisecond arithmetic on a number. The Temporal-shaped reading of the same anchor is useRenderedAt in render.js, and rendering an instant is @uniflowed/web's Time.
hook
useTimeAgo
export hook useTimeAgo(
value: Date | string | number,
options?: {|
readonly serverValue?: string,
/** Override the schedule. `null` works it out once and leaves it. */
readonly interval?: number | null,
readonly locale?: string,
|},
): string { ... }
"3 minutes ago", kept true while the reader looks at it.
Before hydration and on the first client render this is serverValue, defaulting to the instant's UTC ISO string — the same choice @uniflowed/web's Time makes, and for the same reason: the relative form depends on a clock and a locale that the server does not have, so rendering it on both sides would be a hydration mismatch by construction. The text is in the markup for a crawler and for a reader with no JavaScript, and becomes relative once the page is alive.
The update rate follows the distance rather than being fixed: a label from this minute is redrawn every second, one from this hour every thirty, and an older one every minute. That is why this is not "call useNow and format it" — a fixed one-second clock re-renders a week-old timestamp 604,800 times to no effect.