Reactで、自動ログアウトの前に残り時間を知らせて延長する方法

​​React​dialog,localStorage,Playwright,TypeScript,アクセシビリティ,カスタムフック

操作がないまま一定時間たつと自動でログアウトする画面では、ログアウトの少し前に残り時間をダイアログで知らせ、ボタン1つで延長できるようにしておくと、入力の途中でいきなりログアウトされることを防げます。ここでは、ログアウトする時刻(期限)を1つだけ持ち、その期限と現在時刻の差で「通常」「警告中」「ログアウト」を切り替える useIdleTimeout フックと、警告を出すダイアログを作ります。

筆者は検証環境で、10分操作がないとログアウトし、残り60秒で警告を出す画面を作りました。時計を Playwright で進めながら、警告の出る時刻、延長、Esc キー、複数のタブでの動きを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し、Chromium 153 を Playwright 1.63.0 で操作しました。時間は Playwright の Clock で止めた時計を進めて確かめており、本物の時計で10分待ってはいません。Firefox と Safari、本当にタブをバックグラウンドに回したときや OS がスリープしたときの動き、スクリーンリーダーでの読み上げ、サーバー側のセッションとの連携は試していません。

残り60秒でダイアログを出し、押せば10分延びる

できあがりの動きです。9時9分ちょうどにダイアログが出て、残りの秒数が減っていきます。「ログインを続ける」を押すとダイアログが閉じ、ログアウトする時刻が押した時刻の10分後に延びます。

自動ログアウトの1分前に出る警告のダイアログで、「ログインを続ける」を押すとダイアログが閉じ、ログアウト予定の時刻が延びる様子
残り60秒で出たダイアログの「ログインを続ける」を押すと、ログアウト予定が 9:10:00 から 9:19:04 に延びる。その後の9分は1コマ1分の早送り

画面の上の帯は GIF のために重ねたもので、帯の「ログアウト予定」は、フックが保存した期限をそのまま読んで出しています。

警告を出す長さは、WCAG 2.2 の達成基準 2.2.1(タイミング調整可能)を目安にしました。延長できるようにする場合の条件は、期限が来る前に警告し、スペースキーを押すような簡単な操作で延長できる時間を少なくとも20秒与えることと、少なくとも10回は延長できることです。ここでは余裕を見て60秒にしました。延長できる回数には上限を設けていません。

期限を1つ持ち、残り時間は現在時刻との差で出す

フックの全体です。

ts
import { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';

export type Phase = 'active' | 'warning' | 'expired';

type Options = {
  timeoutMs: number; // 操作がないまま、この時間がたったらログアウトする
  warningMs: number; // ログアウトのこの時間前から、警告を出す
  onTimeout: () => void;
  storageKey?: string;
};

// 操作とみなすイベント
const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'wheel'] as const;

// localStorage が使えないときは、このタブの中だけで動く
function readShared(key: string): number | null {
  try {
    const n = Number(localStorage.getItem(key) ?? NaN);
    return Number.isFinite(n) ? n : null; // 値がないときや数値でないときは使わない
  } catch {
    return null;
  }
}
function writeShared(key: string, deadline: number) {
  try {
    localStorage.setItem(key, String(deadline));
  } catch {}
}

export function useIdleTimeout({ timeoutMs, warningMs, onTimeout, storageKey = 'session-deadline' }: Options) {
  const [phase, setPhase] = useState<Phase>('active');
  const [remainingSec, setRemainingSec] = useState(0);
  const deadlineRef = useRef(0); // ログアウトする時刻(Date.now() の値)
  const phaseRef = useRef<Phase>('active');
  const handleTimeout = useEffectEvent(onTimeout);

  // 期限を決め直し、ほかのタブにも知らせる
  const setDeadline = useCallback(
    (deadline: number) => {
      deadlineRef.current = deadline;
      writeShared(storageKey, deadline);
    },
    [storageKey],
  );

  // 今の時刻と期限の差から、状態を決め直す
  const check = useCallback(() => {
    if (phaseRef.current === 'expired') return;
    const shared = readShared(storageKey);
    if (shared !== null) deadlineRef.current = shared; // ほかのタブで延びていれば合わせる
    const remaining = deadlineRef.current - Date.now();
    const next: Phase = remaining <= 0 ? 'expired' : remaining <= warningMs ? 'warning' : 'active';
    phaseRef.current = next;
    setPhase(next);
    setRemainingSec(next === 'warning' ? Math.ceil(remaining / 1000) : 0);
  }, [storageKey, warningMs]);

  useEffect(() => {
    if (phase === 'expired') handleTimeout();
  }, [phase]);

  useEffect(() => {
    setDeadline(Date.now() + timeoutMs); // 開いた時点を、最初の操作とみなす
    check();
    const id = setInterval(check, 500);

    const onActivity = () => {
      if (phaseRef.current !== 'active') return; // 警告中の操作では延ばさない
      const next = Date.now() + timeoutMs;
      if (next - deadlineRef.current < 1000) return; // 書き込みは1秒に1回まで
      setDeadline(next);
    };
    // バックグラウンドのタブではタイマーが遅れるので、戻ってきたらすぐ確かめる
    const onVisible = () => {
      if (document.visibilityState === 'visible') check();
    };
    for (const type of ACTIVITY_EVENTS) window.addEventListener(type, onActivity, { passive: true });
    document.addEventListener('visibilitychange', onVisible);
    return () => {
      clearInterval(id);
      for (const type of ACTIVITY_EVENTS) window.removeEventListener(type, onActivity);
      document.removeEventListener('visibilitychange', onVisible);
    };
  }, [check, setDeadline, timeoutMs]);

  const extend = useCallback(() => {
    if (phaseRef.current === 'expired') return;
    setDeadline(Date.now() + timeoutMs);
    check();
  }, [check, setDeadline, timeoutMs]);

  const logout = useCallback(() => {
    setDeadline(0); // 期限を過去にする。ほかのタブも次の確認でログアウトする
    check();
  }, [check, setDeadline]);

  return { phase, remainingSec, extend, logout };
}

ログアウトする時刻は deadlineRef に Date.now() の値で持ちます。操作のたびに書きかわる値なので、state ではなく ref にしました。画面に出す必要があるのは、今が「通常」「警告中」「ログアウト」のどれかを表す phase と、警告中の残り秒数 remainingSec だけです。

check は 0.5 秒ごとに呼ばれ、期限と現在時刻の差から phase を決め直します。通常のあいだは remainingSec を 0 のままにしているので、毎回同じ値を setPhase と setRemainingSec に渡すことになります。React は、新しい値が今の state と Object.is で同じなら描き直しを省くので、通常のあいだは 0.5 秒ごとに呼んでも、子のコンポーネントまで描き直すことはありません。

残り時間を「1秒ごとに1を引く」形で数えず、毎回差から出しているのは、タイマーが遅れる場面があるからです。MDN の setTimeout のページによると、Chrome は、見えなくなってから5分を超えたタブなどで、条件によってはタイマーを1分に1回しか確かめません。差から出していれば、タイマーが遅れても、次に動いたときに正しい状態になります。タブが見える状態に戻ったときには、Page Visibility API の visibilitychange イベントで、すぐに check を呼んでいます。

タイマーが15分間まったく動かなかった場合も試しました。Playwright の page.clock.fastForward は、時刻を飛ばし、その間に予定されていたタイマーを多くても1回だけ動かします。公式の説明によると、ノートPCのふたをしばらく閉じていたときと同じ状態になります。本当にスリープさせたわけではありませんが、近い状態を時計の操作で作りました。開いた直後に15分飛ばすと、警告のダイアログを一度も出さずに、ログアウトの画面になりました。

text
開いた直後                    9:00:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
15分飛ばした                  9:15:00.0 | ログアウトしました | ダイアログ: 閉じている | 保存された期限 9:10:00.0
onTimeout が呼ばれた回数: 1
ダイアログの開閉: []

ログアウトしたときの処理(onTimeout)は、phase が 'expired' に変わったときのエフェクトから呼びます。渡された関数は useEffectEvent で包んでいるので、親が毎回新しい関数を渡してもエフェクトの依存には入りません。StrictMode を付けた開発ビルドでも本番ビルドでも、onTimeout が呼ばれたのは1回だけでした。

警告が出たあとは、ボタンを押したときだけ延ばす

onActivity は、マウスやキーの操作のたびに期限を「今から10分後」に置き直します。ただし、phase が通常のときだけです。警告が出たあとにマウスが少し動いただけでダイアログが閉じると、利用者は警告が出たことに気づかないかもしれません。そこで、警告中は「ログインを続ける」のボタンか Esc キーで、はっきり延長を選んでもらうことにしました。

1つのタブで、警告の前後の動きを確かめた結果です。左の列が操作、右が時刻、画面の様子、保存された期限です。

text
開いた直後                        9:00:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
8分59.5秒後                     9:08:59.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
9分後                          9:09:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:10:00.0
  フォーカスのある要素: ログインを続ける
警告中にマウスとキーを操作                9:09:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:10:00.0
さらに30秒後                      9:09:30.0 | マイページ | ダイアログ: 開いている(あと 30 秒) | 保存された期限 9:10:00.0
「ログインを続ける」を押した               9:09:30.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:19:30.0
8分59.5秒後                     9:18:29.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:19:30.0
さらに0.5秒後                     9:18:30.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:19:30.0
Esc キーを押した                   9:18:30.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:28:30.0
9分59.5秒後(何もしない)              9:28:29.5 | マイページ | ダイアログ: 開いている(あと 1 秒) | 保存された期限 9:28:30.0
さらに0.5秒後                     9:28:30.0 | ログアウトしました | ダイアログ: 閉じている | 保存された期限 9:28:30.0
onTimeout が呼ばれた回数: 1
ダイアログの開閉: 9:09:00.0 開いた / 9:09:30.0 閉じた / 9:18:30.0 開いた / 9:18:30.0 閉じた / 9:27:30.0 開いた / 9:28:30.0 閉じた

9分ちょうどにダイアログが開き、フォーカスは「ログインを続ける」のボタンにあります。警告中にマウスを動かし、キーを押しても、期限は 9:10:00 のままで、30秒後には残り30秒になりました。ボタンを押すと、期限は押した 9:09:30 の10分後になります。最後に何もしないでいると、期限の 9:28:30 にログアウトの画面に変わりました。

警告の前の操作には、pointermove のように短い間隔で何度も届くイベントも含まれます。期限を書き直すのは、前の期限より1秒以上延びるときだけにしました。3分たったところで、0.02 秒ごとに3秒間(150回)マウスを動かしたときの結果です。

text
3分後                            9:03:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
3秒間マウスを150回動かした                9:03:03.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:13:02.0
  その間の localStorage への書き込み: 3 回
さらに8分58.5秒後                    9:12:01.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:13:02.0
さらに0.5秒後                       9:12:02.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:13:02.0

localStorage への書き込みは3回で済み、期限は最後に書いた 9:03:02 の10分後になりました。警告もその1分前の 9:12:02 に出ています。

警告は dialog 要素の showModal で出す

ダイアログは、HTML の dialog 要素を showModal() で開きます。

tsx
import { useEffect, useRef } from 'react';

type Props = {
  open: boolean;
  remainingSec: number;
  onExtend: () => void;
  onLogout: () => void;
};

export function TimeoutDialog({ open, remainingSec, onExtend, onLogout }: Props) {
  const ref = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = ref.current!;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby="timeout-title"
      aria-describedby="timeout-desc"
      onCancel={(e) => {
        e.preventDefault(); // Esc キーでは閉じずに、延長として扱う
        onExtend();
      }}
    >
      <h2 id="timeout-title">まもなく自動でログアウトします</h2>
      <p id="timeout-desc">
        操作がないため、あと <strong className="sec">{remainingSec}</strong> 秒でログアウトします。
      </p>
      <button type="button" onClick={onExtend}>ログインを続ける</button>
      <button type="button" onClick={onLogout}>今すぐログアウト</button>
    </dialog>
  );
}

MDN の showModal() のページにあるとおり、showModal() で開くと、ダイアログの外の要素は操作できない状態(inert)になります。検証では、開いた直後のフォーカスは最初のボタン「ログインを続ける」にありました。

Esc キーを押すと、dialog 要素で cancel イベントが起きます。何もしなければ、ブラウザがダイアログを閉じます。そこで preventDefault() でブラウザが閉じるのを止め、延長として扱いました。ダイアログを閉じるのは、open が false に変わったときのエフェクトだけです。

onCancel を付けた場合と付けない場合で、警告が出た直後に Esc キーを押して比べました。付けた場合は、期限が 9:19:00 に延びます。

text
9分後                  9:09:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:10:00.0
Esc キーを押した           9:09:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:19:00.0
さらに59.5秒後            9:09:59.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:19:00.0
さらに0.5秒後             9:10:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:19:00.0

onCancel を付けない場合は、ダイアログが閉じても期限は 9:10:00 のままで、1分後に何の知らせもなくログアウトしました。フックの phase は警告中のままなので、open が true から変わらず、ダイアログも開き直されません。

text
9分後                  9:09:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:10:00.0
Esc キーを押した           9:09:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
さらに59.5秒後            9:09:59.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
さらに0.5秒後             9:10:00.0 | ログアウトしました | ダイアログ: 閉じている | 保存された期限 9:10:00.0

コンポーネントから使う

フックとダイアログを組み合わせた画面です。ログアウトすると、App が「ログアウトしました」の画面に切り替えます。

tsx
import { useState } from 'react';
import { TimeoutDialog } from './TimeoutDialog';
import { useIdleTimeout } from './useIdleTimeout';

function Session({ onTimeout }: { onTimeout: () => void }) {
  const { phase, remainingSec, extend, logout } = useIdleTimeout({
    timeoutMs: 10 * 60 * 1000,
    warningMs: 60 * 1000,
    onTimeout,
  });
  return (
    <main>
      <h1>マイページ</h1>
      <p>操作がないまま10分たつと、自動でログアウトします。</p>
      <TimeoutDialog open={phase === 'warning'} remainingSec={remainingSec} onExtend={extend} onLogout={logout} />
    </main>
  );
}

export function App() {
  const [loggedIn, setLoggedIn] = useState(true);
  if (!loggedIn) {
    return (
      <main>
        <h1>ログアウトしました</h1>
        <button type="button" onClick={() => setLoggedIn(true)}>もう一度ログイン</button>
      </main>
    );
  }
  return (
    <Session
      onTimeout={() => {
        console.log('onTimeout が呼ばれた');
        setLoggedIn(false);
      }}
    />
  );
}
注意

画面側の自動ログアウトは、利用者に知らせるための仕組みです。OWASP の Session Management Cheat Sheet は、セッションの期限切れはサーバー側で強制しなければならない、としています。ブラウザ側の時刻や値は書きかえられるからです。実際のアプリでは、onTimeout でサーバーのログアウトを呼び、「ログインを続ける」でもサーバーにリクエストを送ってセッションを延ばす必要があります。この記事の検証では、サーバーとのやりとりは作っていません。

サーバーは最後にリクエストを受けた時刻から数えるのがふつうなので、画面の操作だけで期限を延ばしていると、画面は「通常」のままサーバー側のセッションが先に切れることがあります。操作で延ばすときも、ときどきサーバーにリクエストを送るか、サーバーの期限に合わせる必要があります。同じページのAutomatic Client Logout の節にも、カウントダウンで警告を出す例があります。

タブが複数あっても、期限をそろえる

同じサイトを2つのタブで開いていると、期限をタブごとに持っていると、片方のタブで作業していても、もう片方のタブは「操作がない」と判断して警告を出すことになります。そこで期限を localStorage に保存しました。localStorage は、同じオリジンのページで共有されるので、どのタブで書いた期限も、ほかのタブの check が 0.5 秒以内に読み取って合わせます。

同じブラウザで2つのタブを開き、タブAだけを操作した結果です。Playwright で止めた時計は、2つのタブで共通です。

text
2つのタブを開いた直後
  タブA 9:00:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
  タブB 9:00:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:10:00.0
5分後にタブAをクリックした(0.5秒後)
  タブA 9:05:00.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:15:00.0
  タブB 9:05:00.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:15:00.0
9分30秒後
  タブA 9:09:30.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:15:00.0
  タブB 9:09:30.0 | マイページ | ダイアログ: 閉じている | 保存された期限 9:15:00.0
14分後
  タブA 9:14:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:15:00.0
  タブB 9:14:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:15:00.0
タブAで「ログインを続ける」を押した(0.5秒後)
  タブA 9:14:00.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:24:00.0
  タブB 9:14:00.5 | マイページ | ダイアログ: 閉じている | 保存された期限 9:24:00.0
さらに9分後
  タブA 9:23:00.5 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:24:00.0
  タブB 9:23:00.5 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 9:24:00.0
タブAで「今すぐログアウト」を押した(0.5秒後)
  タブA 9:23:01.0 | ログアウトしました | ダイアログ: 閉じている | 保存された期限 0
  タブB 9:23:01.0 | ログアウトしました | ダイアログ: 閉じている | 保存された期限 0
onTimeout が呼ばれた回数: タブA 1 / タブB 1

5分後にタブAをクリックすると、タブBの期限も 9:15:00 に延び、9分30秒の時点でどちらにも警告は出ていません。14分の時点で両方に出た警告は、タブAで「ログインを続ける」を押すと、タブBでも閉じました。タブAで「今すぐログアウト」を押すと、期限が 0 になり、タブBも次の check でログアウトしています。

localStorage は、ブラウザの設定などで使えないことがあります。readShared と writeShared の中を try/catch で囲み、使えないときはそのタブの中だけの期限で動くようにしました。getItem と setItem が例外を投げるようにして1つのタブで試すと、警告、延長、ログアウトはふだんと同じ時刻に起きました。

text
開いた直後                  9:00:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 (localStorage が使えない)
9分後                    9:09:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 (localStorage が使えない)
「ログインを続ける」を押した         9:09:00.0 | マイページ | ダイアログ: 閉じている | 保存された期限 (localStorage が使えない)
8分59.5秒後               9:17:59.5 | マイページ | ダイアログ: 閉じている | 保存された期限 (localStorage が使えない)
さらに0.5秒後               9:18:00.0 | マイページ | ダイアログ: 開いている(あと 60 秒) | 保存された期限 (localStorage が使えない)
さらに60秒後                9:19:00.0 | ログアウトしました | ダイアログ: 閉じている | 保存された期限 (localStorage が使えない)
onTimeout が呼ばれた回数: 1

​Posted by 86