Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/** The four bands Chromium reports. Anything else normalises to `null`. */
export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g"

/**
 * The coarse label consumers actually branch on.
 *
 * `"unknown"` is a first-class answer, not a placeholder for "bad": it means the
 * engine told us nothing, which happens on every Safari, on Firefox desktop, on
 * the server, and during the first (hydrating) client render. Gate degradations
 * on `quality === "poor"`, never on `quality !== "good"`, so an engine that

Installation

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

Prompt

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 reachabilityuse-online answers "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: a downlink of 0 means either "no estimate yet" or "currently offline", and only reachability can tell those apart — which is exactly why this hook normalises that 0 to null instead of guessing.
  • unknown is not poor — the honest answer where an engine reports nothing, and the single most abused state in adaptive-loading code. Gate degradations on quality === "poor", never on quality !== "good", so a Safari visitor gets the full experience instead of a permanent low-fidelity build. Degrade upward, and let a stated saveData preference outrank any measurement.
  • Worst band winseffectiveType saturates at 4g, 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 to fair without 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: 0 when it has nothing to say, so a naive reading turns silence into the worst possible connection. Normalising non-positive numbers to null keeps them out of the classification entirely.
  • Cached snapshot identityuseSyncExternalStore calls getSnapshot during 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 preferencesaveData comes from the connection object when it exists and from the prefers-reduced-data media query when it does not. The trap is that an engine which never heard of that feature still answers matches: false; only mql.media (serialised back as "not all") reveals that the question was never understood, which is the difference between "declined" and "never asked".

On This Page