Reactで、タブを見ていないあいだは定期的な更新を止める方法

​​React​AbortController,TanStack Query,TypeScript,useEffectEvent,useSyncExternalStore,カスタムフック

2秒ごとにサーバーへ問い合わせて表示を新しくする画面は、別のタブを見ているあいだも問い合わせを続けます。問い合わせて表示を新しくしても、その画面を見ている人はいません。document.visibilityState が hidden のあいだはタイマーを止め、visible に戻った瞬間に1回問い合わせ直せば、見ていないあいだの問い合わせをなくせます。

その動きを、カスタムフック usePolling にまとめました。あわせて、setInterval のままの書き方と、TanStack Query の refetchInterval で、裏に回したあいだの問い合わせがどう変わるかを比べました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、TanStack Query 5.105.1、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Node.js で作った検証用のサーバーから配信しました。タブの切り替えは、Chromium の DevTools Protocol で行っています(理由は最後の節に書きました)。Firefox と Safari、スマートフォンのブラウザ、ウィンドウを最小化した場合、5分以上裏に回した場合は試していません。

上にブラウザのタブが2つ並ぶ。「在庫の状況」のタブでは、問い合わせた回数が3回から4回に増える。カーソルで「別のページ」のタブを押すと、そのページに出ている「サーバーが受けた問い合わせ」は4回のまま変わらない。「在庫の状況」のタブに戻すと、すぐに回数が5回になり、数字がピンクで光る
裏に回しているあいだはサーバーへの問い合わせが増えず、戻した瞬間に1回問い合わせる様子

GIF の「在庫の状況」は、この記事の usePolling で2秒ごとに問い合わせています。「別のページ」には、サーバーが受けた問い合わせの回数を0.25秒ごとに出しました。4秒ほど「別のページ」を見ているあいだ、回数は増えていません。カーソル、押した位置の輪、右上のクリックの回数は、説明のため、撮影したあとで重ねたものです。数字は、変わった瞬間だけピンクにしています。

タブが見えているかを usePageVisible で受け取る

タブが見えているかどうかは、MDN の Page Visibility API の説明にあるとおり、document.visibilityState で分かります。別のタブに切り替えたり、ウィンドウを最小化したりすると hidden になり、そのたびに document で visibilitychange イベントが起きます。MDN は使いどころの例として、見えていないあいだはサーバーへ更新を問い合わせない情報画面を挙げています。

React でこの値を読むには、useSyncExternalStore を使います。公式リファレンスの、ブラウザの API を購読する例では navigator.onLine を読んでいますが、ここでは同じ形で visibilitychange を購読します。

ts
import { useSyncExternalStore } from 'react';

function subscribe(onChange: () => void) {
  document.addEventListener('visibilitychange', onChange);
  return () => document.removeEventListener('visibilitychange', onChange);
}

function getSnapshot() {
  return document.visibilityState === 'visible';
}

// サーバーで描画するときは「見えている」として扱う
function getServerSnapshot() {
  return true;
}

export function usePageVisible() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

subscribe をコンポーネントの外に置いているのは、描画のたびに別の関数を渡すと、React が購読をやり直すためです。getServerSnapshot は、サーバーで描画するときと、その結果をハイドレーションするときにだけ使われます。この記事ではサーバーでの描画は試していません。

見えているあいだだけタイマーを動かす

usePolling は、見えているあいだだけ setTimeout で次の問い合わせを予約します。visible が false になるとエフェクトのクリーンアップ関数が動き、予約したタイマーを消して、問い合わせの途中ならそれも中止します。

ts
import { useEffect, useEffectEvent, useRef } from 'react';
import { usePageVisible } from './usePageVisible';

export function usePolling(
  poll: (signal: AbortSignal) => Promise<void>,
  intervalMs: number,
) {
  const visible = usePageVisible();
  const onPoll = useEffectEvent(poll);
  // 最後に問い合わせが終わった時刻(まだなら 0)
  const lastDoneRef = useRef(0);

  useEffect(() => {
    if (!visible) return; // 隠れているあいだはタイマーを置かない

    const controller = new AbortController();
    let timerId: number | undefined;

    const tick = async () => {
      try {
        await onPoll(controller.signal);
        lastDoneRef.current = Date.now();
      } catch {
        // 失敗の表示は poll の中で行う。中止されたときは何もしない
      }
      if (!controller.signal.aborted) {
        timerId = window.setTimeout(tick, intervalMs);
      }
    };

    // 前の問い合わせから intervalMs 以上たっていれば、すぐに問い合わせる
    const wait = Math.max(0, intervalMs - (Date.now() - lastDoneRef.current));
    timerId = window.setTimeout(tick, wait);

    return () => {
      controller.abort();
      window.clearTimeout(timerId);
    };
  }, [visible, intervalMs]);
}

書き方で気をつけたのは、次の3つです。

1つ目は、setInterval ではなく、問い合わせが終わってから次の setTimeout を設定していることです。サーバーの応答が2秒より遅くても、問い合わせが重なりません。

2つ目は、戻ったときの待ち時間です。最後に問い合わせが終わった時刻を lastDoneRef に残しておき、そこから intervalMs 以上たっていれば待たずに問い合わせます。長く裏に回していたなら、戻った瞬間に問い合わせ直します。一瞬だけ別のタブを見て戻った場合は、残りの時間だけ待ちます。検証環境で、2.4秒の問い合わせのあと3.0秒に隠して3.6秒に戻したところ、次の問い合わせは4.4秒でした。隠さなかった場合と同じ2秒おきの時刻です。

3つ目は、poll を useEffectEvent で包んでいることです。Effect Event の中では、呼んだ時点の最新の props や state を読めます。また、エフェクトの依存配列には入れません。そのため、使う側が描画のたびに新しい関数を渡しても、タイマーがやり直しになりません。公式リファレンスにあるとおり、Effect Event はエフェクトの中(またはほかの Effect Event)からしか呼べません。

使う側では、受け取った signal を fetch に渡します。中止されたときは、例外をそのまま投げ直して、エラーの表示を出さないようにしています。

tsx
import { useState } from 'react';
import { usePolling } from './usePolling';
import { StatusView, type Status } from './StatusView';

export function HookStatus({ delay }: { delay: number }) {
  const [status, setStatus] = useState<Status | null>(null);
  const [error, setError] = useState<string | null>(null);

  usePolling(async (signal) => {
    try {
      const res = await fetch(`/api/status?mode=hook&delay=${delay}`, { signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setStatus(await res.json());
      setError(null);
    } catch (e) {
      if (signal.aborted) throw e;
      setError(String(e));
      throw e;
    }
  }, 2000);

  return <StatusView label="usePolling" status={status} error={error} />;
}

StatusView は、回数・時刻・タブの状態を並べて出すだけのコンポーネントです。delay は、検証用のサーバーが応答を返すまでの待ち時間で、問い合わせの途中で隠したときの確認に使いました。

setInterval のままだと、裏でも問い合わせ続ける

比べる相手は、useEffect の中で setInterval を使っただけの書き方です。

tsx
useEffect(() => {
  const load = async () => {
    const res = await fetch('/api/status?mode=naive');
    setStatus(await res.json());
  };
  load();
  const id = window.setInterval(load, 2000);
  return () => window.clearInterval(id);
}, []);

ページを開いてから8秒後に別のタブを前に出し、28秒後に元のタブへ戻して、サーバーが問い合わせを受けた時刻を記録しました。TanStack Query の3つの設定も、同じ手順で測っています。

5つの書き方について、サーバーが問い合わせを受けた時刻を点で並べたグラフ。8秒から28秒の別のタブを見ているあいだも、setInterval だけの書き方と refetchIntervalInBackground: true は点が続いている。この記事の usePolling と、TanStack Query の初期設定、refetchOnWindowFocus: false は、そのあいだに点がない。usePolling は28秒ちょうどの点がピンク
裏に回した20秒間の問い合わせの有無を、書き方ごとに並べたグラフ

裏に回していた20秒間の問い合わせの回数は、次のとおりです。

書き方 裏に回した20秒間 戻ったあと最初の問い合わせの時刻
useEffect と setInterval だけ 10回 28.4秒
この記事の usePolling 0回 28.0秒
TanStack Query(初期設定のまま) 0回 28.0秒
TanStack Query(refetchOnWindowFocus: false) 0回 28.4秒
TanStack Query(refetchIntervalInBackground: true) 7回 28.0秒

setInterval のままの書き方は、裏に回したあと最初の1回が少し遅れ(6.4秒の次が9.4秒)、そのあとは2秒おきに問い合わせ続けました。ブラウザは裏のタブのタイマーを遅らせますが、止めるわけではありません。MDN の setTimeout の説明によると、Chrome は、見えなくなって5分未満のページではタイマーを1秒に1回まとめて確かめます。2秒おきの問い合わせは、この間隔より長いので、ほぼそのまま動いたことになります。5分を超えると1分に1回まで遅らせる場合があるとも書かれていますが、この記事では試していません。

usePolling は、裏に回した8.0秒から戻した28.0秒まで、1回も問い合わせていません。戻した瞬間(28.0秒)に問い合わせているのは、最後の問い合わせ(6.4秒)から2秒以上たっていたためです。

問い合わせの途中で隠したときと、StrictMode のとき

応答に1.5秒かかるようにして、問い合わせの途中(4.5秒)で別のタブを前に出しました。4.0秒に始まった問い合わせは、サーバーが応答を返す前に接続が切られ、中止されました。10.0秒に戻すと、すぐに次の問い合わせが始まりました。

text
--- 問い合わせに1.5秒かかる
visibilitychange: 4.5s hidden, 10.0s visible
  #1 0.5s ok
  #2 4.0s aborted
  #3 10.0s ok
  #4 13.5s pending

#4 が pending なのは、記録を取った14秒の時点で、まだ応答を返していなかったためです。

開発用のビルドで <StrictMode> を付けても、開いた直後の問い合わせは1回だけでした。StrictMode の公式リファレンスにあるとおり、開発中はエフェクトが「実行→クリーンアップ→実行」と動きます。開いた直後は lastDoneRef が0なので、1回目の実行では0ミリ秒後にタイマーが設定されます。このタイマーは、動く前にクリーンアップで消されます。裏に回して戻したときも、問い合わせは1回だけでした。

TanStack Query は、初期設定のままでも裏に回すと問い合わせを止める

TanStack Query の useQuery を使っているなら、自分でフックを書く必要はありません。refetchInterval で2秒ごとに問い合わせるようにしても、refetchIntervalInBackground の初期値が false なので、タブが裏にあるあいだは問い合わせません。

tsx
const { data, error } = useQuery({
  queryKey: ['status', mode],
  queryFn: async ({ signal }): Promise<Status> => {
    const res = await fetch(`/api/status?mode=${mode}`, { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  },
  refetchInterval: 2000,
  refetchIntervalInBackground: mode === 'query-bg',
  refetchOnWindowFocus: mode !== 'query-nofocus',
});

戻った瞬間(28.0秒)に問い合わせているのは、refetchOnWindowFocus の働きです。TanStack Query の、ページに戻ったときの再取得についての説明によると、この設定は初期値が true で、ページに戻ったときにデータが古くなっていれば問い合わせ直します。TanStack Query の初期設定の説明にあるとおり、staleTime を設定しなければ、取得したデータはすぐに古いとみなされます。「戻った」かどうかは、初期設定では visibilitychange で判断しています。

refetchOnWindowFocus: false にすると、戻ってから最初の問い合わせは28.4秒になりました。問い合わせの時刻は0.4秒、2.4秒、4.4秒、6.4秒、28.4秒と続き、戻ったあとも裏に回す前と同じ2秒おきの刻みに乗っています。戻った瞬間ではなく、次の刻みまで待ったことになります。

refetchIntervalInBackground: true にすると、裏に回しているあいだも問い合わせが続きました。ただし間隔は3秒おき(9.4秒、12.4秒…)に延びていて、setInterval だけの書き方(2秒おき)とは違いました。なぜ3秒になったのかは調べていません。

タブの切り替えを Playwright で確かめるときの注意

最初は Playwright でページを開き、別のページを bringToFront() で前に出して確かめようとしました。ところが、元のページの document.visibilityState は visible のままで、visibilitychange も起きませんでした。

text
Playwright: 別のタブを前に出したあとの visibilityState = visible

TanStack Query の説明にも、Chrome DevTools の「Emulate a focused page」を有効にしていると document.visibilityState が visible のままになり、visibilitychange が起きない、という注意があります。テストのツールやブラウザの設定によっては、タブを切り替えても裏に回ったことにならない場合があります。

そこでこの記事では、Playwright でページにつながず、Chromium をそのまま起動して、DevTools Protocol の HTTP のエンドポイントで新しいタブを開いたり(/json/new)、元のタブを前に出したり(/json/activate)しました。こうすると、タブを切り替えたときに hidden と visible が記録されました。

​Posted by 86