useOnlineStatus Hook
navigator.onLine plus online and offline events. SSR should not read the navigator, and the event pair is what keeps the flag honest.
- react
- hooks
- browser
- interview
Reading navigator.onLine during render looks like a one-liner hook. On the server navigator is undefined and the render throws. On a hydrating client, the first paint must match the HTML you already sent. If the server printed “online” and the client’s first render reads false, you get a hydration mismatch.
useOnlineStatus is navigator.onLine plus the window online / offline events. The snapshot is a hint from the browser, not a ping to your API. The events are what keep the flag up to date after mount. Defaulting to true on the server (and on the client’s first paint) is the usual hydration choice: most pages are not an offline banner, and a false default flashes that banner on every SSR load.
- Do not read
navigatorduring render on the server. There is no navigator in Node.- Default
truefor SSR and the hydrating client so HTML matches. Readnavigator.onLinein an effect (or ingetSnapshotafter hydrate).- Subscribe to
windowonlineandoffline. Cleanup both. The property alone does not update React state.navigator.onLine === truecan still mean LAN without WAN, a captive portal, or lie-fi.falseis the more trustworthy edge.useSyncExternalStoreis the concurrent-safe subscribe API.useStateplususeEffectis what most whiteboards still expect.
navigator.onLine plus online/offline events, SSR default
Whiteboard version:
import { useEffect, useState } from 'react';
function useOnlineStatus() {
const [online, setOnline] = useState(true);
useEffect(() => {
function sync() {
setOnline(navigator.onLine);
}
sync();
window.addEventListener('online', sync);
window.addEventListener('offline', sync);
return () => {
window.removeEventListener('online', sync);
window.removeEventListener('offline', sync);
};
}, []);
return online;
}
function SaveHint() {
const online = useOnlineStatus();
return (
<p role="status">
{online ? 'Changes save to the server.' : 'Offline. Changes stay on this device.'}
</p>
);
}
Procedure:
- Server render returns
true. HTML has no offline banner. - Client’s first render also returns
true. Hydration matches. - After paint, the effect reads
navigator.onLineand subscribes. If the browser is actually offline, you flip tofalsein a second commit. That is a layout change, not a mismatch. - The browser fires
offline/onlinewhen it thinks connectivity changed. You set state fromnavigator.onLine(or from the event type:offlinemeansfalse). - Unmount removes both listeners. One leftover
offlinehandler is a leak and asetStateon a tree that is gone.
Why not useState(() => navigator.onLine)? Lazy init still runs on the server if the component renders there. typeof navigator !== 'undefined' ? navigator.onLine : true avoids the throw, but the client’s first render would then read the real flag. Offline users hydrate with false against server true. Same mismatch. Keep the first client paint on the default, then sync in the effect.
Why default true
| Default | Server HTML | Offline user after hydrate | Typical product |
|---|---|---|---|
true |
No banner | Banner appears after paint | Matches “we assumed the network exists” |
false |
Banner on every page | Banner was already there | Every SSR visitor sees a lie for one frame |
undefined |
You must not branch on it in HTML | You wait for the effect | Honest, extra loading UI |
true is not a claim that the network works. It is a claim that the first HTML should look like the common case so hydration can attach. An offline-first shell that always shows a status pill can use undefined and render a neutral placeholder until the effect runs. Do not invent false just to feel conservative.
navigator.onLine lies
MDN is blunt: browsers disagree about what “online” means. A machine can be “online” to a hotel LAN that has no WAN, or to a captive portal that only serves a login page. Airplane mode usually flips the flag. Pulling the ethernet cable usually does. Your API being 500 does not.
The event pair does not fix that. online means the browser changed its mind, not that fetch('/api/health') will succeed. For a machine-coding status chip, the hook is enough. For sync-critical UI, pair it with a cheap heartbeat and treat the flag as a hint. That is the offline status indicator brief.
function useOnlineStatusWithProbe(url) {
const browserOnline = useOnlineStatus();
const [reachable, setReachable] = useState(true);
useEffect(() => {
if (!browserOnline) {
setReachable(false);
return undefined;
}
let cancelled = false;
fetch(url, { method: 'HEAD', cache: 'no-store' })
.then((res) => {
if (!cancelled) setReachable(res.ok);
})
.catch(() => {
if (!cancelled) setReachable(false);
});
return () => {
cancelled = true;
};
}, [browserOnline, url]);
return browserOnline && reachable;
}
Do not put that probe in the base hook. Interviews want the event subscription first. The probe is the follow-up when they ask “what if onLine is wrong.”
useSyncExternalStore
navigator.onLine is an external mutable source. Concurrent React can render, discard, render again. useSyncExternalStore is the API that keeps the snapshot consistent:
import { useSyncExternalStore } from 'react';
function subscribe(onStoreChange) {
window.addEventListener('online', onStoreChange);
window.addEventListener('offline', onStoreChange);
return () => {
window.removeEventListener('online', onStoreChange);
window.removeEventListener('offline', onStoreChange);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true;
}
function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
getServerSnapshot must be the hydration default (true). getSnapshot runs on the client and may differ after hydrate. React will not use getSnapshot for the hydrating render when you provide the server snapshot. Returning a new object from getSnapshot would infinite-loop. A boolean is fine.
useEventListener can attach the two events if you already have that helper. You still need the initial sync() (or getSnapshot) because no event fires on mount.
Footguns
navigator.onLine with no events. You read once and never update. Unplugging the network leaves the UI on “online” until a full reload.
Events with no initial read. You wait for a transition. A user who loads already offline stays on the SSR default until they go online and offline again.
Subscribing on document only. The events are fired on window. Use window.
Conditional hook. if (typeof window !== 'undefined') useOnlineStatus() breaks Rules of Hooks. Always call it. Branch inside the effect, or pass a server snapshot.
Treating online as “the mutation succeeded.” Queue writes. Do not trust this flag as ACK. See the offline-first notes in the machine-coding page.
Interview out-loud answer
I would not read
navigator.onLineduring render. On the server it throws or disagrees with the client, and a disagreeing first client paint is a hydration mismatch. I default totrueso SSR HTML matches the common case, then I sync fromnavigator.onLinein an effect and subscribe towindowonlineandoffline. Cleanup removes both.truecan still be a LAN without WAN, so I would not use the flag as proof the API is reachable.useSyncExternalStorewithgetServerSnapshot: () => trueis the concurrent-safe version of the same idea.
Further reading
- MDN:
Navigator.onLine - MDN:
onlineevent - MDN:
offlineevent - HTML: Browser state
- react.dev:
useSyncExternalStore
Related on this site
- useEventListener Hook: window events with a stable handler
- Offline Status Indicator: banner, live region, heartbeat
- Hydration Mismatches: first client paint must match server HTML
- useSyncExternalStore Overview: subscribe, snapshot, server snapshot
- Custom Hooks Design: browser APIs behind a
useprefix - Memory Leaks in SPAs: leftover
offlinelisteners - useFetch With AbortController: fail a request when you go offline
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.