useNetworkInfo
A useSyncExternalStore hook that reports connection quality rather than reachability — effectiveType, downlink, rtt and saveData normalised to nullable fields, plus one coarse quality label.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-network-info.jsonPrompt
Build a React + TypeScript "useNetworkInfo" hook (React only — no npm
dependencies; the browser Network Information API plus one media query).
Contract
- `useNetworkInfo(): NetworkInfo`. No arguments and no options object: there is
exactly one network, and every knob worth turning (band thresholds, which
signals count) is a constant in the file rather than a prop.
- `NetworkInfo` is `{ isSupported: boolean; effectiveType: "slow-2g" | "2g" |
"3g" | "4g" | null; downlink: number | null; rtt: number | null; saveData:
boolean | null; quality: "poor" | "fair" | "good" | "unknown" }`.
- Every measurement is `T | null`, and `null` means "this engine did not report
it" — never collapse it to `0` or `false`. `saveData: null` is "never asked",
not "said no"; `downlink: null` is "no estimate", not "no bandwidth".
- `isSupported` answers exactly one question: does `navigator.connection` exist
here. It does NOT promise numbers — Firefox on Android exposes the object with
none of these fields on it, so `isSupported: true` with an all-null reading is
a reachable state that consumers must survive.
- `quality` is the coarse label to branch on, and it is the WORST band across
the signals that were actually reported: effectiveType (`slow-2g` / `2g` →
poor, `3g` → fair, `4g` → good), downlink in Mbps (`>= 1.5` good, `>= 0.4`
fair, otherwise poor), rtt in ms (`<= 275` good, `<= 1400` fair, otherwise
poor). Nothing reported at all → `"unknown"`.
- The returned object is referentially stable: the same identity comes back on
every render until a field really changes, so it is safe in a dependency
array and safe to hand to a memoized child.
Behavior
- Build it on `useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)`.
Reading `navigator` during render breaks hydration; the store reads it after
commit instead.
- `getServerSnapshot` returns one shared all-null constant, so the server markup
and the hydrating first paint agree on `isSupported: false` /
`quality: "unknown"`, and React swaps in the measured reading right after
hydration. Nothing here derives from a clock.
- `getSnapshot` MUST return a cached object. React calls it during render and
re-renders whenever the result is not `Object.is`-equal to the previous one, so
returning a fresh object every call is an infinite loop. Keep the last snapshot
at module scope, compare all six fields, and return the previous object when
nothing moved. One cache for the module is correct — there is a single network,
so every consumer can share one identity.
- Resolve the connection as `navigator.connection ?? navigator.mozConnection ??
navigator.webkitConnection ?? null`, guarded by
`typeof navigator === "undefined"`. Declare the shape in a local interface —
do NOT `declare global` on `Navigator`: that augmentation ships with the file
and collides with whatever the buyer's project already declares, and
TypeScript's DOM lib still has no `NetworkInformation`.
- Normalise `effectiveType` against the four known literals. Anything else (a
future `"5g"`) becomes `null` so the union stays a real union, and `quality`
falls back to the raw numbers.
- Normalise a non-finite or non-positive `downlink` / `rtt` to `null`: Chromium
reports `0` when it has no estimate yet AND while the device is offline.
Classifying that as "poor" invents a slow connection out of silence. Telling
"no estimate" apart from "no network" needs reachability, which is
`use-online`'s job and is deliberately not folded in here.
- `saveData` has two sources: `connection.saveData` when it is a boolean,
otherwise the `(prefers-reduced-data: reduce)` media query. An engine that does
not know that feature serialises the query back as `"not all"` and reports
`matches: false` — check `mql.media !== "not all"` before trusting it, or the
hook will claim the visitor declined data saving on a browser that never
offered the choice. Allocate that MediaQueryList once at module scope;
`matchMedia()` returns a new object per call and `getSnapshot` runs on every
render of every consumer.
- `subscribe` attaches `change` to the connection object AND to the media query
list (whichever exist), closing over both, and its cleanup detaches from those
exact objects. Nothing else is subscribed — in particular not window
`online`/`offline`, which is a different question with a different hook.
- Never issue a request. Every number is the engine's own estimate, rounded
(25 kbps, 25 ms) and capped (10 Mbps) on purpose to limit fingerprinting. Do
not "improve" it by timing a fetch on mount; a free estimate is the entire
value of this API.
- Support is uneven and the contract must make that visible rather than hide it:
Chromium fills every field, Firefox exposes the object on Android only, Safari
has never shipped it. Consumers therefore degrade UPWARD — branch on
`quality === "poor"` or `saveData === true` and let `"unknown"` fall through to
the full experience. Gating on `quality !== "good"` hands every Safari visitor
the low-fidelity build forever.
- Precedence, when a consumer has both signals: `saveData === true` outranks any
quality band including `"good"`. A stated preference beats a measurement.
Rendering & styling
- The hook renders nothing and owns no DOM. Consumers branch on `quality` /
`saveData` and use semantic tokens only: `bg-card` / `border` for the reading
panel, `bg-primary` for filled signal bars against `bg-muted` for empty ones,
`text-muted-foreground` for unmeasured `null` values, `text-destructive` (or a
`destructive` badge) for the poor band. No hex, rgb or oklch literals.
- ARIA contract: a signal-strength glyph is decorative — mark it `aria-hidden`
and keep the band as text or a badge next to it. A panel whose contents change
when the connection changes is `role="status"` so the swap is announced once,
politely. Values the engine did not report render as a literal `null`, not as
an empty cell.
- Keyboard and focus: there is nothing to map inside the hook, and controls built
on top stay in the normal tab order (a real `<button>`, Space/Enter to press).
A fidelity toggle uses `aria-pressed` plus a handler guard — never the native
`disabled` attribute on a control the visitor may currently be standing on,
because the browser blurs it to `<body>`. When a branch swaps, change a
control's label rather than unmounting it; if one must go, move focus to a
deliberate successor.
- Any transition between fidelity modes respects `prefers-reduced-motion`
(`motion-reduce:animate-none`) and must still function with motion off. A
"load it anyway" affordance is a real button with a real handler, never a
decorative badge.
Customization levers
- Band thresholds: the three `bandFrom*` helpers are the entire policy. Edit the
numbers, or drop a signal — effectiveType-only classification is one deletion
away, and worst-of keeps working with whatever remains.
- More bands: add a rank to the quality map and a case per helper; nothing else
changes.
- More fields: `type` ("wifi" / "cellular" / …) and `downlinkMax` come off the
same object — widen the local interface and the normaliser. Both are the least
supported members of the API, so keep them nullable and never branch on them
alone.
- Different policies per surface: lift `classify` into an argument if a video
gate and a prefetch gate need different thresholds — they usually do.
- Optimistic first paint: swap the server snapshot for a hand-written "assume
good" reading if your product would rather over-deliver before hydration. Keep
it a module constant so SSR and hydration still agree.
- Pairings: `use-online` for reachability (the boolean this hook deliberately
does not answer), a polling hook for interval tuning (back off on poor, tighten
on good), and the `Save-Data: on` request header for the same preference on the
server render.Concepts
- Quality, not reachability —
use-onlineanswers "is there a network interface", a boolean that flips twice a day; this hook answers "how good is it", a set of estimates the engine keeps revising. They are complementary, not alternatives: adownlinkof0means either "no estimate yet" or "currently offline", and only reachability can tell those apart — which is exactly why this hook normalises that0tonullinstead of guessing. unknownis notpoor— the honest answer where an engine reports nothing, and the single most abused state in adaptive-loading code. Gate degradations onquality === "poor", never onquality !== "good", so a Safari visitor gets the full experience instead of a permanent low-fidelity build. Degrade upward, and let a statedsaveDatapreference outrank any measurement.- Worst band wins —
effectiveTypesaturates at4g, which covers everything from a 1 Mbps cell edge to gigabit fibre, so classifying on it alone calls a struggling phone "good". Taking the worst band across the signals that were actually reported demotes that cell edge tofairwithout inventing a fifth label, and degrades gracefully to "whatever we have" when only one signal exists. - Zero means "no estimate" — Chromium reports
downlink: 0/rtt: 0when it has nothing to say, so a naive reading turns silence into the worst possible connection. Normalising non-positive numbers tonullkeeps them out of the classification entirely. - Cached snapshot identity —
useSyncExternalStorecallsgetSnapshotduring render and re-renders whenever the result is a new object, so building a fresh reading every call is an infinite loop. One module-level cache, a six-field comparison, and the previous object handed back unchanged: one network, one identity, shared by every consumer and safe in a dependency array. - Two sources for one preference —
saveDatacomes from the connection object when it exists and from theprefers-reduced-datamedia query when it does not. The trap is that an engine which never heard of that feature still answersmatches: false; onlymql.media(serialised back as"not all") reveals that the question was never understood, which is the difference between "declined" and "never asked".
useVisualViewport
A hook that tracks the visual viewport — size, offsets and pinch scale — and derives the keyboard occlusion so a bottom bar can sit above the on-screen keyboard.
useBattery
Reads battery level, charging state and time remaining from the Battery Status API, with a four-way support status and a low-power flag that fails open when no reading is available.