ESC

Type to search the knowledge base.

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.

beginner6 min read
  • 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 navigator during render on the server. There is no navigator in Node.
  • Default true for SSR and the hydrating client so HTML matches. Read navigator.onLine in an effect (or in getSnapshot after hydrate).
  • Subscribe to window online and offline. Cleanup both. The property alone does not update React state.
  • navigator.onLine === true can still mean LAN without WAN, a captive portal, or lie-fi. false is the more trustworthy edge.
  • useSyncExternalStore is the concurrent-safe subscribe API. useState plus useEffect is what most whiteboards still expect.

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:

  1. Server render returns true. HTML has no offline banner.
  2. Client’s first render also returns true. Hydration matches.
  3. After paint, the effect reads navigator.onLine and subscribes. If the browser is actually offline, you flip to false in a second commit. That is a layout change, not a mismatch.
  4. The browser fires offline / online when it thinks connectivity changed. You set state from navigator.onLine (or from the event type: offline means false).
  5. Unmount removes both listeners. One leftover offline handler is a leak and a setState on 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.

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.onLine during render. On the server it throws or disagrees with the client, and a disagreeing first client paint is a hydration mismatch. I default to true so SSR HTML matches the common case, then I sync from navigator.onLine in an effect and subscribe to window online and offline. Cleanup removes both. true can still be a LAN without WAN, so I would not use the flag as proof the API is reachable. useSyncExternalStore with getServerSnapshot: () => true is the concurrent-safe version of the same idea.

Further reading

Related guides