Reactで、ブラウザの通知を出す方法

​​React​Notifications API,Playwright,TypeScript

ブラウザの Notifications API を使うと、Web ページからパソコンの画面の端に通知を出せます。React の側でやることは3つです。通知の許可の状態を state に持つこと、ボタンを押したときに許可を求めること、許可されたら new Notification() で通知を出すことです。

以下のコードは、Chromium の実際の画面で動かし、許可の確認の吹き出しと通知をマウスでクリックして確かめたものです。許可が「ブロック」になったときの表示や、https でも localhost でもないページで起きることも書きます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換しました。仮想の画面(Xvfb)に日本語表示の Chromium を開き、ページの中のボタンは Playwright で、ブラウザが出す確認の吹き出しと通知は xdotool でクリックしました。StrictMode を付けた開発ビルドでも同じ操作をしたところ、結果は本番ビルドと同じでした。Windows や macOS の通知センター、Firefox と Safari、スマートフォンのブラウザは試していません。

「通知を許可する」を押すとブラウザの確認の吹き出しが出て、「許可する」を押すと表示が「通知: 許可されています」に変わる。「通知を出す」を押すと画面右下に「保存が終わりました」の通知が出て、それをクリックすると通知が消え、ページのログに click と close が加わる
許可を求めてから通知を出し、通知をクリックするまで。クリックした場所と、そのたびに変わった表示を、その瞬間だけピンクで囲んでいる(上の帯とピンクの枠は GIF 用に重ねたもの)

許可の状態を state に持つ

通知を出せるかどうかは、Notification.permission で分かります。値は次の3つです。

  • granted:利用者が許可した
  • denied:利用者がブロックした
  • default:まだ決まっていない。MDN によると、この場合も denied と同じように扱われる

画面の表示をこの値に合わせて切り替えたいので、React の state に持たせます。

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

export type Permission = NotificationPermission | 'unsupported';

function readPermission(): Permission {
  // Notification がない環境(サーバー側の描画など)や、https でないページでは使えない
  if (typeof Notification === 'undefined' || !window.isSecureContext) return 'unsupported';
  return Notification.permission;
}

// 通知の許可の状態を持ち、ブラウザの設定で変わったときにも追いかける
export function useNotificationPermission() {
  const [permission, setPermission] = useState<Permission>(readPermission);

  useEffect(() => {
    if (!navigator.permissions) return;
    let status: PermissionStatus | undefined;
    let cancelled = false;
    const update = () => setPermission(readPermission());
    navigator.permissions.query({ name: 'notifications' }).then((s) => {
      if (cancelled) return;
      status = s;
      s.addEventListener('change', update);
    });
    return () => {
      cancelled = true;
      status?.removeEventListener('change', update);
    };
  }, []);

  // ボタンを押したときに呼ぶ。結果は 'granted' / 'denied' / 'default' のどれか
  const request = useCallback(async () => {
    const result = await Notification.requestPermission();
    setPermission(result);
    return result;
  }, []);

  return { permission, request };
}

Notification.permission は読み取るだけのプロパティで、Notification のイベントも click・close・error・show の4つだけです。許可の状態が変わったことは、ここからは分かりません。そこで、Permissions API の navigator.permissions.query({ name: 'notifications' }) で受け取る PermissionStatus の change イベントを使います。ページを開いたまま、利用者がサイトの設定で許可を変えたときに、表示を追いかけるためです。

検証環境では、ページを開いたまま、Chrome DevTools Protocol の Browser.setPermission で許可を「ブロック」に変えました。すると、画面の表示は「通知: ブロックされています」に変わり、ボタンが消えました。「許可」に戻すと、表示も「許可されています」に戻りました。ブラウザのサイトの設定画面から変えたときの動きは、試していません。

readPermission で isSecureContext を見ている理由は、記事の最後の「安全なコンテキストでないページでは使えない」の節で説明します。

許可は、ボタンを押したときに求める

許可を求めるのは Notification.requestPermission() です。Promise を返し、利用者が選んだ結果の文字列で解決します。

呼ぶ場所は、ボタンの onClick の中にしました。MDN の Notifications API の使い方を説明したページには、許可はボタンのクリックのような利用者の操作に応じて求めるべきだと書かれています。そうしないと、今後のブラウザでは許可を求められなくなる、とも書かれています。ページを開いた瞬間の useEffect で呼ぶのは避けます。

tsx
const { permission, request } = useNotificationPermission();

async function handleRequest() {
  const result = await request();
  add(`requestPermission: ${result}`);
}

// 表示の部分
<p id="permission">通知: {label[permission]}</p>
{permission === 'default' && <button onClick={handleRequest}>通知を許可する</button>}
{permission === 'denied' && <p>通知を使うには、ブラウザのサイトの設定で許可してください。</p>}
{permission === 'granted' && (
  <>
    <button onClick={handleNotify}>通知を出す</button>
    <button onClick={handleTimer}>5秒後に知らせる</button>
  </>
)}

add は、画面の下に結果を1行ずつ足していく関数です。「通知を許可する」を押すと、冒頭の GIF のように、アドレスバーの下に「(ページのオリジン)が次の許可を求めています」という吹き出しが出ました。吹き出しのボタンごとに、requestPermission() の結果と画面の表示は次のようになりました。

  • 「許可する」:granted が返り、表示は「通知: 許可されています」になった
  • 「ブロック」:denied が返り、表示は「通知: ブロックされています」になった
  • 右上の ×:default が返り、表示は「通知: まだ決まっていません」のままだった

×で閉じたときは、許可もブロックもされず、default のままでした。もう一度ボタンを押せば、吹き出しがまた出ます。

一度「ブロック」を押すと、そのあと requestPermission() を呼んでも、利用者の操作を待たずに、1秒以内に denied が返りました。ページを読み込み直しても denied のままです。ページの側からは、もう許可を求め直せません。そのため、denied のときはボタンを出さず、ブラウザのサイトの設定で許可するよう、文章で伝えています。

ポイント

「ブロック」を押されると、ページのコードからは許可を求め直せません。いきなり吹き出しを出すのではなく、通知が何に使われるかが分かる場所にボタンを置き、利用者が押したときに求めます。

通知を出し、クリックされたらタブに戻す

許可されたら、new Notification(タイトル, オプション) で通知を出します。body は本文、tag は通知を見分けるための文字列です。

ts
// 通知を出し、クリックされたらこのタブを前に出して閉じる
export function notify(title: string, options: NotificationOptions, onClick?: () => void) {
  if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return null;
  const n = new Notification(title, options);
  n.onclick = () => {
    window.focus();
    onClick?.();
    n.close();
  };
  return n;
}

この関数は、React のコンポーネントの外に置きました。通知を出すのはボタンを押したときやタイマーが終わったときで、画面の描画とは関係がないからです。ボタンの処理から次のように呼びます。

tsx
function handleNotify() {
  const n = notify('保存が終わりました', { body: '3件のファイルを保存しました', tag: 'save' }, () => add('click'));
  if (n) {
    n.onshow = () => add('show');
    n.onclose = () => add('close');
    n.onerror = () => add('error');
  }
}

ボタンを押すと、画面の右下に通知が出て、ログに show が入りました。通知の本文をクリックすると、click と close が続けて入り、通知が消えました。close が入ったのは、onclick の中で n.close() を呼んでいるからです。

tag が同じ通知を、0.5秒あけて2つ出したときの結果です。

text
同じ tag で2つ出したとき: [ '1つ目 show', '1つ目 close', '2つ目 show' ]

2つ目を出すと1つ目が閉じ、画面の右下に残った通知は1つだけでした。続けて tag が違う通知を2つ出すと、今度は前の通知の上に積み重なって出ました。

上は、同じ tag で2つ出したあと。画面右下には「2つ目」の通知だけが残っている。下は、続けて tag が a と b の通知を出したあと。「2つ目」の上に A と B の通知が積み重なっている
画面右下の通知。上が同じ tag で2つ出したあと、下がそこへ tag が違う2つを足したあと

MDN の使い方のページにも、同じ tag の通知がすでに表示されていれば、前の通知を閉じて新しい通知を出すと書かれています。同じ内容を何度も知らせるときは、tag をそろえておくと、通知がたまっていきません。

window.focus() を呼ばないと、タブは前に出なかった

通知をクリックした人は、たいていそのページに戻りたいはずです。そこで onclick の中で window.focus() を呼んでいます。

仕様の通知をクリックしたときの手順では、ブラウザは通知を出したページを前に出すのがよい(should)とされています。MDN の click イベントのページにも、標準の動きは通知のページにフォーカスを移すことだと書かれています。しかし、検証環境の Chromium 153 で window.focus() の行を消した版を試すと、通知をクリックしても、裏に回したタブは前に出ませんでした。

text
# window.focus() を呼ぶ版
  visibilitychange: hidden
  画面: 通知: 許可されています / message=通知から戻りました / ...
  visibilitychange: visible

# window.focus() を消した版
  visibilitychange: hidden
  画面: 通知: 許可されています / message=通知から戻りました / ...

(ログの行は一部を省いています)

どちらの版でも onclick は動いています。違うのは、最後に visibilitychange: visible が来るかどうかです。window.focus() を呼んだ版だけ、タブが前に戻りました。仕様でも、ブラウザに対して、click イベントの中では window.focus() が効くようにすることを勧めています。

タブを見ていないときだけ通知を出す

利用者がそのページを見ているなら、ページの外に通知を出すより、ページの中に表示したほうが分かりやすいはずです。document.visibilityState を見て、hidden のときだけ通知を出すようにしました。

tsx
const timer = useRef<number>(undefined);

useEffect(() => () => window.clearTimeout(timer.current), []);

// 5秒後に終わるタイマー。タブが見えていないときだけ通知を出す
function handleTimer() {
  setMessage('');
  timer.current = window.setTimeout(() => {
    add(`timer: visibilityState=${document.visibilityState}`);
    if (document.visibilityState === 'hidden') {
      notify('タイマーが終わりました', { body: '5秒たちました', tag: 'timer' }, () => setMessage('通知から戻りました'));
    } else {
      setMessage('タイマーが終わりました');
    }
  }, 5000);
}

ページを見たまま5秒待つと、通知は出ず、ページの中に「タイマーが終わりました」と出ました。「5秒後に知らせる」を押してから Ctrl+T で新しいタブを前に出すと、5秒後の visibilityState は hidden になり、画面の右下に通知が出ました。その通知をクリックすると、元のタブが前に戻り、ページに「通知から戻りました」と出ました。この表示は、通知の onclick から呼ばれる処理で出しているものです。

補足

Playwright でページを操作していると、別のタブを前に出しても、裏に回ったタブの visibilityState は visible のままでした。そのため、この確認だけは Playwright を使わずに Chromium を起動し、xdotool のキー操作でタブを切り替えています。E2E テストで同じことを確かめるときは注意が要ります。

スマートフォンでは、Service Worker から出す

MDN の Notification() コンストラクターのページには、このコンストラクターは、ほとんどのモバイルブラウザで TypeError を投げると書かれています。代わりに Service Worker を登録し、ServiceWorkerRegistration.showNotification() を使います。この記事のコードは、パソコンのブラウザで、ページを開いているあいだに通知を出す場合だけを扱っています。スマートフォンでの動きと、ページを閉じていても届くプッシュ通知は、試していません。

安全なコンテキストでないページでは使えない

Notifications API の許可は、安全なコンテキスト(secure context)でないと得られません。MDN の Secure contexts のページによると、https のページのほかに、http://localhost も安全なコンテキストとして扱われます。開発中に http://localhost で試せるのは、このためです。

検証環境で、example.test というホスト名を 127.0.0.1 に向けて、http://example.test:5185/ を開きました。

text
http://localhost:5185/ { isSecureContext: true, hasNotification: true, permission: 'default' }
http://example.test:5185/ { isSecureContext: false, hasNotification: true, permission: 'denied' }
  requestPermission(): denied(1秒以内に返った)

安全なコンテキストでないページでも、Notification 自体はありました。ただし Notification.permission は最初から denied で、requestPermission() を呼んでも、利用者の操作を待たずに、1秒以内に denied が返りました。

これをそのまま画面に出すと、利用者が一度もブロックしていないのに「通知: ブロックされています」と出し、サイトの設定で許可するよう求めてしまいます。そこで readPermission では、isSecureContext が false のときに 'unsupported' を返し、「このページでは使えません」と表示するようにしました。

​Posted by 86