Reactで、オンラインかオフラインかを画面に表示する方法

​​React​Playwright,TypeScript,useSyncExternalStore,カスタムフック

ブラウザのネットワークが切れたら、画面の見出しの横に「オフライン」と出し、その下に「13:19 からオフラインです」と表示します。つながったら「オンラインに戻りました」を3秒だけ出して消します。ネットワークの状態は navigator.onLine と、online・offline のイベントで読みます。

ただし、navigator.onLine が true でも、サーバーに届くとは限りません。検証用のサーバーを止めると、表示は「オンライン」のままでした。サーバーに届くかどうかは、実際にリクエストを送って確かめます。その書き方も後半で試しています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Node.js で作った検証用のサーバーから配信しました。ネットワークの切り替えは、Playwright の context.setOffline() で行っています。実際に Wi-Fi やケーブルを抜いたときの動き、Firefox と Safari、スマートフォンのブラウザ、サーバーレンダリングは試していません。

見出し「猫のメモ」の横に水色の「オンライン」のバッジがある。「ネットワークを切る」を押すと、バッジが灰色の「オフライン」に変わり、その下に「13:19 からオフラインです」と出る。「ネットワークをつなぐ」を押すと、バッジが「オンライン」に戻り、「オンラインに戻りました」と出る。つないでから3秒ほどでメッセージが消える
ネットワークを切るとオフラインの表示に変わり、つなぐと「戻りました」が3秒だけ出る様子

GIF の下の「検証用の操作」の枠、右上のクリックの回数、「つないでから」の秒数、カーソルと押した位置の輪は、撮影のために重ねたものです。枠のボタンは Playwright からブラウザのネットワークを切り替えるだけで、記事のコンポーネントとは関係ありません。バッジとメッセージは、変わった瞬間だけピンクにしています。

ネットワークの状態を読むフック

ネットワークの状態は、React の公式リファレンスにある、ブラウザの API を購読する例と同じ形の useOnlineStatus で読みます。同じフックは、以前の記事「ReactアプリをService Workerでオフライン対応にする方法」でも、オフラインの帯を出すのに使いました。

ts
import { useSyncExternalStore } from 'react';

function subscribe(callback: () => void) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // ブラウザでの値
    () => true, // サーバーで描画するときの値
  );
}

MDN の navigator.onLine の説明によると、この値が変わると、window で online か offline のイベントが起きます。検証環境で context.setOffline(true) を呼ぶと、offline のイベントが1回起き、navigator.onLine は false になりました。false に戻すと online のイベントが起き、true に戻りました。

3つ目の引数は、サーバーで HTML を作るときに使う値です。サーバーにはブラウザの API である navigator.onLine がないので、公式リファレンスのサーバーレンダリングの説明と同じく、常に true を返します。この記事の検証は、ブラウザだけで描画しています。

切れた時刻と「戻りました」を出すコンポーネント

useOnlineStatus が返すのは、いまオンラインかどうかだけです。「何時から切れているか」と「いま戻ったところか」は、値が変わったときに、コンポーネントの側で覚えておきます。

tsx
import { useEffect, useRef, useState } from 'react';
import { useOnlineStatus } from './useOnlineStatus';

const timeFormat = new Intl.DateTimeFormat('ja-JP', { hour: '2-digit', minute: '2-digit' });

export function ConnectionStatus() {
  const isOnline = useOnlineStatus();
  const [offlineSince, setOfflineSince] = useState<Date | null>(null);
  const [justReconnected, setJustReconnected] = useState(false);
  const prevOnline = useRef(isOnline);

  useEffect(() => {
    if (prevOnline.current === isOnline) return; // 最初の描画では何もしない
    prevOnline.current = isOnline;
    if (!isOnline) {
      setOfflineSince(new Date());
      setJustReconnected(false);
      return;
    }
    setJustReconnected(true);
    const id = setTimeout(() => setJustReconnected(false), 3000);
    return () => clearTimeout(id);
  }, [isOnline]);

  let message = '';
  if (!isOnline) {
    message = offlineSince
      ? `${timeFormat.format(offlineSince)} からオフラインです`
      : 'オフラインです';
  } else if (justReconnected) {
    message = 'オンラインに戻りました';
  }

  return (
    <>
      <span className={isOnline ? 'badge online' : 'badge offline'}>
        {isOnline ? 'オンライン' : 'オフライン'}
      </span>
      <p role="status" className="connection-message" data-online={isOnline}>
        {message}
      </p>
    </>
  );
}

prevOnline には、前に見たときの値を入れておきます。ページを開いた直後は、前の値といまの値が同じなので、何もしません。こうしないと、ページを開いただけで「オンラインに戻りました」が出てしまいます。

オフラインになったら、その時刻を offlineSince に入れます。オンラインに戻ったら justReconnected を true にし、3秒後に false に戻すタイマーを仕掛けます。

メッセージの <p> は、文が空のときも画面に置いたままにしています。role="status" の要素は、MDN の status ロールの説明にあるとおり、中身が変わったことを、利用者の操作を遮らずに伝えるための場所です。スクリーンリーダーでの読み上げは、この記事では試していません。

検証環境で試したところ、表示は次のように変わりました。

操作 バッジ メッセージ
ページを開いた直後 オンライン (なし)
ネットワークを切った直後 オフライン 13:19 からオフラインです
つないだ直後 オンライン オンラインに戻りました
つないでから2秒後 オンライン オンラインに戻りました
つないでから3.3秒後 オンライン (なし)

開発用のビルドで StrictMode を付けて動かしても、同じ結果でした。

戻ってすぐにまた切れたとき

つながったり切れたりを短い間にくり返すと、「戻りました」を消すタイマーが残っているうちに、またオフラインになります。上のコードでは、isOnline が変わると、前の回のクリーンアップ関数がタイマーを取り消します。React の useEffect のリファレンスにあるとおり、依存する値が変わると、新しい値でセットアップが動く前に、前の回のクリーンアップ関数が動くからです。

検証では、切って0.5秒でつなぎ、その1秒後にまた切りました。表示はすぐに「13:19 からオフラインです」に変わり、その3秒後も同じままでした。古いタイマーがオフラインの表示を消すことはありませんでした。

ページを開いた時点でオフラインだったとき

Service Worker で配信したページなど、オフラインのまま開かれる場合は、切れた時刻が分かりません。そのときは offlineSince が null のままなので、時刻を付けずに「オフラインです」とだけ出します。

この場合は、navigator.onLine が常に false を返すように差し替えてから開いて確かめました。バッジは「オフライン」、メッセージは「オフラインです」になりました。実際にオフラインの状態でページを開いたわけではありません。

navigator.onLine が true でもサーバーに届くとは限らない

MDN の navigator.onLine の説明には、ブラウザや OS によって判定のしかたが違うと書かれています。たとえば、LAN につながっていればオンラインとみなされ、その LAN からインターネットに出られなくても true になることがあります。そのため、この値を見て機能を止めるのではなく、オフラインらしいことを知らせる目安にとどめるよう勧めています。

検証環境でも、ページを開いたあとで検証用のサーバーだけを止めると、navigator.onLine は true のまま、イベントも起きませんでした。バッジは「オンライン」のままです。

見出し「猫のメモ」の横のバッジは「オンライン」のまま。その下の「サーバーとの接続を確かめる」ボタンを押した結果として「サーバーにつながりません」と出ている
検証用のサーバーを止めたあと。バッジはオンラインのままだが、リクエストは届かない

上の画像のいちばん上の行は、撮影のために重ねた説明です。「サーバーにつながりません」の文字は、撮影のためにピンクにしています。

サーバーに届くかどうかは、実際にリクエストを送って確かめます。次の checkServer は、/api/ping に HEAD のリクエストを送り、ステータスが200番台の応答が返れば true を返します。検証用のサーバーでは、/api/ping は中身のない 204 の応答を返すようにしました。

ts
export async function checkServer(): Promise<boolean> {
  try {
    const res = await fetch('/api/ping', {
      method: 'HEAD',
      cache: 'no-store',
      signal: AbortSignal.timeout(5000),
    });
    return res.ok;
  } catch {
    return false; // ネットワークエラー、または5秒でタイムアウト
  }
}

cache: 'no-store' を付けると、MDN の Request.cache の説明にあるとおり、ブラウザはキャッシュを見ずにサーバーへ取りに行きます。キャッシュに残った古い応答で「つながった」と判定しないためです。

signal には AbortSignal.timeout() で作ったシグナルを渡します。5秒たっても応答がなければリクエストを取り消し、fetch はエラーになります。応答がまったく返ってこない場合に、表示がいつまでも「確かめています…」のままにならないようにするためです。

この関数をボタンから呼ぶコンポーネントは、次のとおりです。

tsx
import { useState } from 'react';
import { checkServer } from './checkServer';

type Result = 'idle' | 'checking' | 'ok' | 'ng';

export function ServerCheck() {
  const [result, setResult] = useState<Result>('idle');

  async function handleClick() {
    setResult('checking');
    setResult((await checkServer()) ? 'ok' : 'ng');
  }

  return (
    <div className="server-check">
      <button onClick={handleClick} disabled={result === 'checking'}>
        サーバーとの接続を確かめる
      </button>
      <p data-result={result}>
        {result === 'checking' && '確かめています…'}
        {result === 'ok' && 'サーバーにつながりました'}
        {result === 'ng' && 'サーバーにつながりません'}
      </p>
    </div>
  );
}

検証環境で、条件を変えてボタンを押した結果です。時間は、ボタンを押してから結果が出るまでを測りました。

条件 navigator.onLine 結果 かかった時間
サーバーが動いている true サーバーにつながりました 115ms
サーバーを止めた true サーバーにつながりません 51ms
ブラウザのネットワークを切った false サーバーにつながりません 61ms
サーバーが応答を返さない true サーバーにつながりません 5092ms

サーバーを止めた場合は、接続がすぐに断られました(ブラウザのエラーは ERR_CONNECTION_REFUSED)。ネットワークを切った場合は、ブラウザがリクエストを送らずにすぐ失敗しました(ERR_INTERNET_DISCONNECTED)。どちらも結果はすぐに出ました。サーバーが応答を返さない場合は、5秒のタイムアウトまで待ってから「つながりません」になりました。

ポイント

バッジのような目安の表示には navigator.onLine を使い、送信できるかどうかは fetch の結果で判断します。オフラインの表示が出ていなくても、リクエストが失敗することはあります。

​Posted by 86