Reactで、トースト通知を表示する仕組みを作る方法

​​React​createPortal,Playwright,TypeScript,useSyncExternalStore,アクセシビリティ

「保存しました」のような短い通知を画面の端に出し、数秒で消すトースト通知は、ライブラリを使わなくても100行ほどで作れます。通知の一覧を React の外の小さなストアに持たせると、コンポーネントの中でも、fetch を包んだふつうの関数の中でも、同じ toast() を呼ぶだけで通知を出せます。自動で消すタイマーには、マウスを乗せたりフォーカスを当てたりしているあいだは止める処理も入れます。

筆者は検証環境で、通知を出すだけの小さな画面を作り、Chromium を Playwright で操作して、通知が消えるまでの時間や、止めたときの残り時間を測りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは 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 で操作しました。時間は、ページの中で通知の要素が増えた・消えた時刻を記録して測っています。スクリーンリーダーで実際に読み上げられるかどうか、Firefox と Safari、スマートフォンのブラウザ、出入りのアニメーションは試していません。

作るものは、ストアと Toaster と toast() の3つ

仕組みは3つの部品に分けました。

  • 通知の一覧を持つストア(toast-store.ts)。toast() と dismiss() で一覧を変える
  • 一覧を画面に出すコンポーネント(Toaster)。アプリに1か所だけ置く
  • 1件ぶんの通知(ToastItem)。自分のタイマーで、時間が来たら dismiss() を呼ぶ

Toaster は、アプリのいちばん外側に1回だけ置きます。

tsx
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
    <Toaster />
  </StrictMode>,
);

Context で一覧を配る作り方もあります。ただ、Context の値は、コンポーネントの中で useContext を呼んで読むものです。そのため、toast() を呼べるのもコンポーネントの中だけになります。API の関数の中で通信の失敗を知らせたい場合もあるので、ここでは React の外にストアを置き、useSyncExternalStore で読む形にしました。

通知の一覧を持つストアを作る

ストアは、一覧の配列と、変わったことを知らせる相手(listener)の集まりを持つだけです。

ts
// トーストの一覧を持つ小さなストア(React の外からも toast() を呼べる)
export type ToastType = 'success' | 'error';
export type Toast = {
  id: number;
  message: string;
  type: ToastType;
  duration: number | null; // null なら自動では閉じない
};

const MAX_TOASTS = 3;
let toasts: Toast[] = [];
let nextId = 1;
const listeners = new Set<() => void>();

function emit() {
  for (const listener of listeners) listener();
}

export function subscribe(listener: () => void) {
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
  };
}

export function getSnapshot() {
  return toasts;
}

export function toast(
  message: string,
  options: { type?: ToastType; duration?: number | null } = {},
) {
  const id = nextId++;
  const item: Toast = {
    id,
    message,
    type: options.type ?? 'success',
    duration: options.duration === undefined ? 4000 : options.duration,
  };
  // 新しい配列を作る(同じ配列を書き換えると、React が変化に気づかない)
  toasts = [...toasts, item].slice(-MAX_TOASTS);
  emit();
  return id;
}

export function dismiss(id: number) {
  toasts = toasts.filter((t) => t.id !== id);
  emit();
}

toast() と dismiss() では、push で配列を書き換えずに、毎回新しい配列を作っています。公式リファレンスにあるとおり、getSnapshot が返す値は書き換えない(immutable な)ものにし、ストアが変わっていないあいだは同じ値を返す必要があります。React は前回の値と Object.is で比べて、違うときだけ再レンダーするからです。

slice(-MAX_TOASTS) で、表示する数を3件までにしています。続けて5回出すと、古い2件が消えて新しい3件が残りました。

text
表示: [ '通知 3×', '通知 4×', '通知 5×' ]

Toaster で一覧を表示する

Toaster は、ストアの一覧を createPortal で document.body の直下に出します。公式リファレンスでは、overflow: hidden などのスタイルを持つ枠の中にあるコンポーネントからでも、ページの上に浮かぶ表示を作れる例として、portal を紹介しています。

tsx
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { dismiss, getSnapshot, subscribe, type Toast } from './toast-store';

export function Toaster() {
  const toasts = useSyncExternalStore(subscribe, getSnapshot);
  // 通知が0件のときも、読み上げ用の領域は置いたままにする
  return createPortal(
    <section className="toaster" aria-label="通知" aria-live="polite">
      <ol>
        {toasts.map((t) => (
          <ToastItem key={t.id} toast={t} />
        ))}
      </ol>
    </section>,
    document.body,
  );
}

外側の section には aria-live="polite" を付け、通知が0件のときも消さずに置いておきます。MDN のライブリージョンの解説には、支援技術が読み上げるのはふつう、ライブリージョンの中身があとから変わったときだと書かれています。空の領域を先に用意しておき、あとから中身を入れるように勧めています。通知が出るたびに aria-live 付きの要素ごと作ると、この順番が守れません。

読み込んだ直後と、通知が1件出たときのアクセシビリティツリーを Playwright で取り出すと、次のようになりました。最初から空の「通知」の領域があり、そこに通知が1件ずつ増えます。

text
- region "通知":
  - list
text
- region "通知":
  - list:
    - listitem:
      - text: 保存しました
      - button "通知を閉じる": ×

一定時間で消し、マウスやフォーカスがあるあいだは止める

1件ぶんの ToastItem は、useEffect の中で setTimeout を始め、時間が来たら自分の id で dismiss() を呼びます。

tsx
export function ToastItem({ toast }: { toast: Toast }) {
  const [paused, setPaused] = useState(false);
  const remaining = useRef(toast.duration);

  useEffect(() => {
    if (paused || remaining.current === null) return;
    const startedAt = Date.now();
    const timer = setTimeout(() => dismiss(toast.id), remaining.current);
    return () => {
      clearTimeout(timer);
      // 止めた時点で、残り時間を減らしておく
      if (remaining.current !== null) remaining.current -= Date.now() - startedAt;
    };
  }, [paused, toast.id]);

  return (
    <li
      className={`toast toast-${toast.type}`}
      onMouseEnter={() => setPaused(true)}
      onMouseLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={() => setPaused(false)}
    >
      <span>{toast.message}</span>
      <button type="button" aria-label="通知を閉じる" onClick={() => dismiss(toast.id)}>
        ×
      </button>
    </li>
  );
}

ポイントは、残り時間を useRef で持つところです。マウスが乗って paused が true になると、エフェクトの後片付けでタイマーを止め、それまでに経った時間を残り時間から引きます。マウスが外れると、残り時間でタイマーを始め直します。残り時間は画面に出さない値なので、state にせず ref にしました。

測った結果は次のとおりです。何もしなければ、出てからちょうど4秒で消えました。出して約1秒後にマウスを乗せると、6秒たっても消えず、マウスを外してから約3秒で消えました。閉じるボタンにフォーカスを当てた場合も同じです。

text
出てから消えるまで:4.00 秒
text
出してから 6.05 秒後(マウスを乗せたまま): [ '通知 1×' ]
乗せるまでの時間:1.05 秒、マウスを外してから消えるまで:2.96 秒
text
出してから 6.03 秒後(フォーカスしたまま): [ '通知 1×' ]
フォーカスするまでの時間:1.02 秒、フォーカスを外してから消えるまで:2.98 秒

フォーカスでも止めるのは、キーボードで閉じるボタンまで移動しているあいだに通知が消えないようにするためです。React の onFocus と onBlur は、公式リファレンスにあるとおり、ブラウザの focus イベントと違ってバブリングします。そのため、li に付けるだけで、中のボタンへのフォーカスを拾えます。

なお、この書き方では、止めているかどうかを1つの paused で持っています。そのため、閉じるボタンにフォーカスを当てたままマウスを乗せて外すと、タイマーが動き出しました。出して約2秒後にマウスを外すと、フォーカスはそのままでも、出してから約5秒で消えました。

text
マウスを外した時刻:出してから 2.07 秒、消えた時刻:出してから 5.05 秒

開発ビルドの StrictMode では、エフェクトが「実行→後片付け→実行」と2回動きます。後片付けでタイマーを止めているので、このときも消えるまでの時間は 4.00 秒で、早く消えたりはしませんでした。

タイマーは通知ごとに持っているので、止まるのはマウスを乗せた1件だけです。3件出して、いちばん新しい1件にマウスを乗せると、ほかの2件は時間どおりに消えました。

「通知を出す」ボタンを3回クリックして通知を3件出し、いちばん下の新しい通知にマウスを乗せた様子。上の2件は時間どおりに消え、マウスを乗せた1件だけが残る。マウスを外すと、その1件も残りの時間で消える
通知を3件出し、いちばん新しい1件にマウスを乗せて止めたときの動き

コンポーネントの外から通知を出す

ストアを React の外に置いたので、コンポーネントではない関数からも toast() を呼べます。次の saveNote は、保存の API を呼んで、結果に応じて通知を出します。

ts
import { toast } from './toast-store';

// コンポーネントではない、ふつうの関数からも通知を出せる
export async function saveNote(text: string) {
  const res = await fetch('/api/notes', { method: 'POST', body: text });
  if (!res.ok) {
    toast(`保存できませんでした(${res.status})`, { type: 'error', duration: null });
    return;
  }
  toast('保存しました');
}

失敗の通知は、読む前に消えると困るので、duration: null にして自動では閉じないようにしました。検証用のサーバーは、本文が空なら 400 を返します。空で保存すると、8秒たっても通知は残っていました。

text
8 秒後の表示: [ '保存できませんでした(400)×' ]

自動で閉じない通知を duration: Infinity で表したくなりますが、これは逆の結果になります。setTimeout に Infinity を渡すと、すぐに呼ばれました。

text
setTimeout(fn, Infinity) が呼ばれるまで:0.1 ms

MDN の setTimeout の解説にあるとおり、待ち時間は符号付き32ビット整数に変換されます。2147483647ms(約24.8日)より大きい値は、あふれたり 0 として扱われたりします。「閉じない」は null のような別の値で表し、タイマーを始めないようにするのが確実です。

モーダルの dialog を開いているあいだは、閉じるボタンを押せない

showModal() で開いた dialog の中で保存し、そこで通知を出すと、通知は表示されますが、閉じるボタンを押せませんでした。閉じるボタンの位置でクリックを受け取る要素を document.elementFromPoint() で調べると、通知ではなく DIALOG が返りました。Playwright のクリックも、2秒待って失敗しました。

text
閉じるボタンの位置でクリックを受け取る要素(elementFromPoint): DIALOG
閉じるボタンのクリック:できない( locator.click: Timeout 2000ms exceeded. )
dialog を開いたまま、出てから消えるまで:4.00 秒

MDN の showModal() の解説によると、モーダルの dialog は top layer に表示され、同じ文書の中の dialog とその子孫以外の要素は inert(操作できない状態)になります。body の直下に出した通知は dialog の子孫ではないので、操作できなくなります。inert 属性の解説にあるとおり、inert の要素はフォーカスもクリックも受け取りません。見た目も、dialog の後ろの ::backdrop に覆われて、少し暗くなりました(下の画像の上側)。

通知の section に popover="manual" を付け、通知が変わるたびに showPopover() で開き直す形も試しました。popover 属性の解説のとおり、開いた popover は top layer に出るので、通知は dialog より手前に表示されました。それでも通知は inert のままで、閉じるボタンの位置で elementFromPoint() が返す要素は DIALOG でした。見た目は手前でも、クリックの対象にはならず、押せない結果は同じでした。

tsx
useEffect(() => {
  const el = ref.current;
  if (!el) return;
  if (el.matches(':popover-open')) el.hidePopover();
  // 開き直すと、あとから開いた dialog よりも手前に来る
  if (toasts.length > 0) el.showPopover();
}, [toasts]);
モーダルの dialog を開いたまま出した通知の、画面右下の部分を縦に2つ並べた画像。上は body の直下に出した通知で、dialog の後ろの背景に覆われて少し暗い。下は popover=&quot;manual&quot; で出した通知で、白くはっきり表示されている
モーダルの dialog を開いたまま出した通知(上:body の直下に出したもの、下:popover 属性で出したもの)

どちらの形でも、自動で消えるタイマーは動き、4秒で消えました。dialog を閉じてから出した通知は、閉じるボタンを押せました。dialog の中の操作を知らせたいときは、dialog を閉じてから toast() を呼ぶのが確実です。

ポイント

トースト通知は、React の外のストアと useSyncExternalStore で作ると、どこからでも toast() で出せます。ストアでは毎回新しい配列を作り、読み上げ用の aria-live の領域は通知がなくても置いておきます。タイマーは通知ごとに持ち、残り時間を ref に入れておくと、マウスやフォーカスがあるあいだだけ止められます。

作るときのポイント

  • 自動で閉じない通知は null で表し、setTimeout に Infinity を渡さない
  • 失敗の通知のように読んでほしいものは、自動で閉じないか、長めに出す
  • モーダルの dialog を開いているあいだは、外に出した通知を操作できないので、dialog を閉じてから出す
  • 通知を重ねて出すなら、件数の上限を決めておく(この記事では3件)

​Posted by 86