Reactで、新しい版を公開したことを画面で知らせる方法

​​React​HTTPキャッシュ,Playwright,TypeScript,Vite,アクセシビリティ,カスタムフック

React の画面は、タブを開いたままにしていると、新しい版を公開しても古い JavaScript のまま動き続けます。ビルドのたびに版の名前を決めて、画面のコードと version.json の両方に入れておけば、画面が定期的に version.json を読み、名前が違ったときに「新しい版が公開されました」と知らせられます。読み込み直すかどうかは利用者に選んでもらうので、入力の途中で画面が勝手に切り替わることはありません。

ここでは Vite でビルドする画面を例に、版の名前を入れる設定、読み比べる useNewVersion フック、知らせを出すコンポーネントを作ります。Service Worker は使いません。Service Worker で画面のファイルを保存している画面では、MDN の Service Worker の使い方にあるとおり、古い Service Worker がページを制御しているあいだは新しいほうが待つため、読み込み直しても古い画面が開くおそれがあります。そうした画面には Service Worker の更新の仕組みで知らせる方法が合い、以前の記事「ReactアプリをService Workerでオフライン対応にする方法」で試しています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、vite 8.3.5、@vitejs/plugin-react 6.2.0 です。tsc で型チェックしてから vite build でビルドし、Node.js で書いた小さな配信サーバーから配信して、Chromium 153 を Playwright 1.63.0 で操作しました。「公開」は、サーバーが配信するフォルダを古い版から新しい版に切り替えて再現しています。時間は Playwright の Clock で時計を進めて確かめており、本物の時計で5分待ってはいません。Firefox と Safari、スクリーンリーダーでの読み上げ、CDN を通した配信は試していません。

公開から5分以内に知らせが出て、押すと新しい版になる

できあがりの動きです。9時0分に開いた画面は、5分ごとのタイマーで確かめます。9時0分30秒に新しい版を公開すると、画面は 9時5分ちょうどに version.json を読み、画面の下に知らせを出します。「読み込み直す」を押すと、画面の版が新しいものに変わります。

開いたままのメモの画面に、新しい版の公開から約4分半後、画面の下に「新しい版が公開されました」という知らせが出て、「読み込み直す」を押すと画面の版が 2026.11.02-1 から 2026.11.02-2 に変わる様子
9:00:30 に公開すると、9:05:00 に知らせが出る。「読み込み直す」を押すと、画面の版が新しくなる。途中の4分ほどは1コマ20秒の早送り

画面の上の帯、カーソル、押したときの輪は GIF のために重ねたもので、「サーバーで公開中の版」は、配信サーバーが返している version.json の版です。

ビルドのたびに版の名前を決め、画面と version.json に入れる

vite.config.ts で、版の名前を1つ決めます。この名前を、画面のコードには Vite の define で埋め込み、サーバーに置くファイルには小さなプラグインで version.json として書き出します。

ts
import { defineConfig, type Plugin } from 'vite';
import react from '@vitejs/plugin-react';

// ビルドごとに版の名前を決める(指定がなければビルドした時刻)
const version = process.env.APP_VERSION ?? new Date().toISOString();

// ビルドした出力に version.json を足す
function versionFile(): Plugin {
  return {
    name: 'version-file',
    apply: 'build',
    generateBundle() {
      this.emitFile({
        type: 'asset',
        fileName: 'version.json',
        source: JSON.stringify({ version }),
      });
    },
  };
}

export default defineConfig({
  plugins: [react(), versionFile()],
  define: {
    __APP_VERSION__: JSON.stringify(version), // 画面のコードに、同じ版の名前を埋め込む
  },
});

define に書いた __APP_VERSION__ は、ビルドのときにコードの中の文字列に置き換わります。Vite の公式ドキュメントにあるとおり、TypeScript で使うには型の宣言が要ります。

ts
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;

version.json を書き出すほうは、Vite のプラグイン API で apply: 'build' にして、ビルドのときだけ動かしています。版の名前は、決まった名前を環境変数 APP_VERSION で渡せるようにし、渡さなければビルドした時刻にしました。時刻を名前にした場合は、中身が同じでも、ビルドし直せば別の版として扱われます。

版の名前を変えて2回ビルドすると、どちらの出力にも version.json ができ、それぞれの JavaScript のファイルには同じ名前が入りました。

text
$ cat dist-v1/version.json; echo; cat dist-v2/version.json; echo
{"version":"2026.11.02-1"}
{"version":"2026.11.02-2"}

$ grep -o "2026\.11\.02-[0-9]" dist-v1/assets/*.js dist-v2/assets/*.js | sort | uniq -c
      2 dist-v1/assets/index--u13FVI0.js:2026.11.02-1
      2 dist-v2/assets/index-DGTQm3mj.js:2026.11.02-2

5分ごとと、タブに戻ったときに読み比べる

useNewVersion フックは、サーバーの version.json を読み、埋め込んだ名前と違えば hasUpdate を true にします。

ts
import { useEffect, useState } from 'react';

// この画面をビルドしたときの版
const CURRENT = __APP_VERSION__;

// サーバーに置いた version.json から、いま公開されている版を読む
async function fetchLatest(signal: AbortSignal): Promise<string | null> {
  try {
    const res = await fetch(`${import.meta.env.BASE_URL}version.json`, {
      cache: 'no-store', // ブラウザの HTTP キャッシュを使わず、毎回サーバーに取りに行く
      signal,
    });
    if (!res.ok) return null;
    const data: unknown = await res.json();
    if (typeof data === 'object' && data !== null && 'version' in data && typeof data.version === 'string') {
      return data.version;
    }
    return null;
  } catch {
    return null; // オフラインや、JSON でない応答のときは、何もしない
  }
}

export function useNewVersion(intervalMs = 5 * 60 * 1000) {
  const [latest, setLatest] = useState<string | null>(null);

  useEffect(() => {
    if (latest !== null) return; // 一度見つけたら、それ以上は問い合わせない
    let controller: AbortController | null = null;

    const check = async () => {
      if (document.visibilityState !== 'visible') return; // 見ていないタブでは問い合わせない
      controller?.abort();
      controller = new AbortController();
      const v = await fetchLatest(controller.signal);
      if (v !== null && v !== CURRENT) setLatest(v);
    };
    const onVisibilityChange = () => {
      if (document.visibilityState === 'visible') check(); // タブに戻ってきたら、すぐ確かめる
    };

    const id = setInterval(check, intervalMs);
    document.addEventListener('visibilitychange', onVisibilityChange);
    window.addEventListener('online', check);
    return () => {
      clearInterval(id);
      document.removeEventListener('visibilitychange', onVisibilityChange);
      window.removeEventListener('online', check);
      controller?.abort();
    };
  }, [intervalMs, latest]);

  return { current: CURRENT, latest, hasUpdate: latest !== null };
}

確かめるのは、次の3つのときです。

見ていないタブでは問い合わせません。その代わり、タブに戻った直後に1回確かめます。新しい版を一度見つけたら latest が埋まり、effect の最初の return で問い合わせをやめます。一度知らせを出せば、それ以上読む必要がないためです。

version.json の URL には import.meta.env.BASE_URL を付けました。Vite の公式ドキュメントにあるとおり、この値はアプリを配信しているパス(base の設定値)です。base でサブディレクトリに置いた場合も、その場所の version.json を読みます(筆者はサブディレクトリでの配信は試していません)。

知らせは role="status" の入れ物の中に出す

知らせのコンポーネントです。

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

export function UpdateBanner() {
  const { hasUpdate } = useNewVersion();
  const [dismissed, setDismissed] = useState(false);
  const show = hasUpdate && !dismissed;

  // role="status" の入れ物は最初から置いておき、中身だけを出し入れする
  return (
    <div role="status" className={show ? 'update-banner' : undefined}>
      {show && (
        <>
          <p>新しい版が公開されました。読み込み直すと、最新の画面になります。</p>
          <button type="button" onClick={() => location.reload()}>
            読み込み直す
          </button>
          <button type="button" onClick={() => setDismissed(true)}>
            あとで
          </button>
        </>
      )}
    </div>
  );
}

MDN の status ロールの説明によると、status は、alert ほど急ぎではない情報を伝えるライブリージョンで、aria-live="polite" と同じ扱いになります。MDN のライブリージョンの説明では、支援技術が読み上げるのは中身が変わったときなので、入れ物を先に置いておき、あとから中身を入れるよう書かれています。そのため、role="status" の div は常に置いておき、中の文とボタンだけを出し入れしています。フォーカスは知らせに移しません。

「読み込み直す」は location.reload() を呼ぶだけです。知らせが出たら自動で読み込み直す作りにもできますが、次の節の出力のとおり、入力欄に書いた文字は、読み込み直すと消えました。書きかけの内容がある画面では、利用者が押すまで待つほうが安全です。

公開してから知らせが出るまで

画面を開き、5分たってから新しい版を公開し、さらに時計を進めました。

text
$ node test/flow.mjs
開いた直後: この画面の版:2026.11.02-1 / 知らせ「」 / version.json の問い合わせ 0回
5分後(まだ公開していない): この画面の版:2026.11.02-1 / 知らせ「」 / version.json の問い合わせ 1回
公開: dist-v2
公開して4分59秒後: この画面の版:2026.11.02-1 / 知らせ「」 / version.json の問い合わせ 1回
公開して5分後: この画面の版:2026.11.02-1 / 知らせ「新しい版が公開されました。読み込み直すと、最新の画面になります。読み込み直すあとで」 / version.json の問い合わせ 2回
さらに15分後: この画面の版:2026.11.02-1 / 知らせ「新しい版が公開されました。読み込み直すと、最新の画面になります。読み込み直すあとで」 / version.json の問い合わせ 2回
「あとで」を押したあと: この画面の版:2026.11.02-1 / 知らせ「」 / version.json の問い合わせ 2回
メモの中身: 書きかけ
読み込み直したあと(あとでを押した画面): この画面の版:2026.11.02-2 / 知らせ「」 / version.json の問い合わせ 2回
メモの中身: ""
ページのエラー: 0

知らせは、公開のあとの最初の問い合わせ(公開して5分後)で出ました。知らせが出てからの15分は、問い合わせが増えていません。「あとで」を押すと知らせが消え、書きかけのメモはそのまま残りました。そのあと読み込み直すと新しい版になり、メモは空になりました。

「読み込み直す」を押した場合も、押したあとの画面は 2026.11.02-2 になり、知らせは出ませんでした。新しい画面は、自分と同じ版の version.json を読むので、知らせを出しません。

text
--- 「読み込み直す」を押す
押す前: この画面の版:2026.11.02-1 / ボタン 1個 / 問い合わせ 1回
押したあと: この画面の版:2026.11.02-2 / ボタン 0個 / 問い合わせ 1回
さらに10分後: この画面の版:2026.11.02-2 / ボタン 0個 / 問い合わせ 3回
ページのエラー: 0

タブを裏に回したときの動きは、document.visibilityState を書き換えて visibilitychange を送り、近い状態を作って確かめました。本当に別のタブを前に出したわけではありません。隠しているあいだの30分は問い合わせが0回で、見える状態に戻した直後に1回問い合わせて、知らせが出ました。

text
--- タブが見えていないとき(再現)
隠して30分: この画面の版:2026.11.02-1 / ボタン 0個 / 問い合わせ 0回
見える状態に戻した直後(時計は進めていない): この画面の版:2026.11.02-1 / ボタン 1個 / 問い合わせ 1回

本物のタブの切り替えで問い合わせが止まるかは、以前の記事「Reactで、タブを見ていないあいだは定期的な更新を止める方法」で、画面のあるブラウザを使って確かめています。

version.json がキャッシュされると、公開に気づけない

version.json をブラウザがキャッシュしてしまうと、新しい版を公開しても、画面は古い version.json を読み続けます。サーバーが version.json に Cache-Control: max-age=600(10分は新しいものとして使ってよい)を付けた場合を試しました。ページの中で fetch を直接呼んで比べています。

text
--- version.json を max-age=600 で配信したとき
公開前 fetch(指定なし): 2026.11.02-1 / サーバーが受けた問い合わせ 1回
公開後 fetch(指定なし): 2026.11.02-1 / サーバーが受けた問い合わせ 1回
公開後 fetch(cache: 'no-store'): 2026.11.02-2 / サーバーが受けた問い合わせ 2回
公開後 fetch(指定なし、もう一度): 2026.11.02-1 / サーバーが受けた問い合わせ 2回
フックの5分後: この画面の版:2026.11.02-1 / ボタン 1個 / 問い合わせ 3回

何も指定しない fetch は、公開したあとも古い 2026.11.02-1 を返し、サーバーには問い合わせが届いていません。cache: 'no-store' を付けると、サーバーに問い合わせて 2026.11.02-2 を受け取りました。フックはこの指定をしているので、5分後に知らせが出ています。

no-store で読んだあとでも、指定のない fetch は古い版を返しました。MDN の Request.cache の説明にあるとおり、no-store はキャッシュを見ずにサーバーから取り、取ったものでキャッシュを書き換えもしないためです。

注意

index.html がキャッシュされていると、「読み込み直す」を押しても古い画面が開くおそれがあります(筆者はこの場合を試していません)。検証の配信サーバーは、MDN の Cache-Control の説明にある、ファイル名にハッシュを入れる配信の例にならい、index.html と version.json に no-cache、assets/ の下のファイルに public, max-age=31536000, immutable を付けています。

サーバーが止まっているときや、オフラインのとき

version.json が 503 を返すあいだや、オフラインのあいだは、fetchLatest が null を返すだけで、知らせは出ず、ページのエラーもありませんでした。

text
--- version.json が 503 を返すとき
503 のあいだ10分: この画面の版:2026.11.02-1 / ボタン 0個 / 問い合わせ 2回 / ページのエラー 0
戻して5分後: この画面の版:2026.11.02-1 / ボタン 1個 / 問い合わせ 3回
--- オフラインのとき
オフラインで10分: この画面の版:2026.11.02-1 / ボタン 0個 / 問い合わせ 0回 / ページのエラー 0
オンラインに戻した直後(時計は進めていない): この画面の版:2026.11.02-1 / ボタン 1個 / 問い合わせ 1回 / 戻してからの問い合わせ 1回

オフラインは Playwright の setOffline で作りました。オンラインに戻すと online イベントで1回問い合わせ、次の5分を待たずに知らせが出ました。

開発サーバー(vite)では、version.json を書き出すプラグインが動かないので、version.json はありません。それでも読みに行くと、ステータスは 200 で、Content-Type は text/html でした。

text
$ ./node_modules/.bin/vite --host 127.0.0.1 --port 5173 --strictPort > vite-dev.log 2>&1 & VP=$!; sleep 3; node test/dev.mjs; kill $VP
この画面の版:2026-11-02T08:11:12.914Z
version.json の応答: 200 text/html
5分後のボタン: 0 個 / ページのエラー 0

Vite の appType の説明にあるとおり、初期設定の 'spa' では、ないファイルの代わりに index.html を返すためです。どの URL にも index.html を返す設定の配信サーバーに version.json を置き忘れた場合も、同じ応答になり、知らせが出ないと考えられます(筆者はこの場合を試していません)。

開発サーバーでは res.ok が true になりましたが、res.json() が失敗して catch に入ります。そのため、5分たっても知らせは出ず、ページのエラーも起きませんでした。

​Posted by 86