Reactトースト通知のキュー管理。複数表示・自動消去・aria-live対応
Reactトースト通知の難所はキュー管理。複数同時表示の制御、ホバーで一時停止、重複のまとめ、aria-liveでの読み上げまでコピペで動くコードで解説。
「保存しました」のトーストを画面の右上に出す。それだけのつもりでした。
でも本番でボタンを連打されたら、同じ通知が7枚重なって画面の右半分が緑色に埋まったんです。しかもその裏では「同期に失敗しました」のエラーが3枚目に隠れていて、誰も気づかない。見た目を作るのは5分。事故るのも5分でした。
トースト通知の本当の難所は、1枚をきれいに出すことじゃありません。何枚も同時に来たとき、どう並べ、どう間引き、いつ消すか——つまりキューの管理です。ここを設計せずにClaude Codeへ「トースト作って」と頼むと、必ず後で連打地獄を踏みます。
この記事は、その通知キューだけに絞ります。複数同時表示の上限、自動消去とホバーでの一時停止、同じ通知のまとめ方、表示位置、そしてaria-liveでスクリーンリーダーにどう読ませるか。コピペで動くReact + TypeScriptのコード付きです。
この記事の要点
- トースト通知の難所は見た目ではなくキュー管理。同時表示の上限・自動消去・一時停止・重複のまとめをProviderに集約すると破綻しない。
- 古い通知から消すと最新の状態が埋もれる。**「新しいものを上、あふれたら古いものから捨てる」**を上限つきで実装する。
- 自動消去は
setTimeoutの残り時間をuseRefに持たせ、ホバーとフォーカス中だけ止める。解除後は最初の5秒に戻さない。 - 同じメッセージの連打は新規追加せず、1枚に「×3」のカウンタでまとめると画面が荒れない。
- スクリーンリーダー対応は
roleの出し分けが肝。成功はstatus、緊急エラーだけalert。領域は常時DOMに置く。
トーストは「行列」だと考えると設計が決まる
トースト通知をUI部品だと思うと、色と影とアニメで悩んで終わります。そうではなく、ラーメン屋の行列だと思ってください。
お客さん(通知)が次々来る。席(画面)には限りがある。並べる順番、待たせる時間、同じ注文がかぶったときの扱い、そして「お一人さま何分まで」のルール。これを決めるのが店主、つまりキューの管理者です。1枚のトーストのCSSは、席の見た目を整えているだけ。混雑したときに崩れるかどうかは、店主の采配で決まります。
具体的に決めるべきルールはこの4つです。
| ルール | 何を決めるか | この記事の方針 |
|---|---|---|
| 同時表示の上限 | 一度に何枚まで見せるか | 最大3枚。あふれたら古い方から捨てる |
| 自動消去 | いつ自分で消えるか | 既定5秒。エラーは長め。ホバー/フォーカス中は止める |
| 重複のまとめ | 同じ通知が来たら | 増やさず1枚にカウンタを足す |
| 読み上げ | 支援技術にどう伝えるか | 成功はstatus、緊急エラーはalert |
この4つを、表示する側(各ボタンやAPI呼び出し)にばらまかず、Providerという1か所の店主に集約します。これが今回いちばん伝えたい設計です。
トースト自体がモーダルとどう違うかは別の話なので、操作をブロックするUIが必要な場面はClaude Codeでモーダルダイアログを安全に実装するを見てください。トーストは操作を止めない、あくまで状態の補足です。
同時表示の上限とは「古いものを捨てる勇気」
複数同時表示でいちばん多い失敗は、来た通知を全部表示しようとすることです。
5枚来たら5枚出す。10枚来たら10枚出す。これだと画面が通知で埋まり、肝心の「今やっている操作」が見えなくなります。行列の全員を店内に詰め込むようなものです。
なので上限を決めます。今回は3枚。問題は「あふれた4枚目をどうするか」です。選択肢は2つ。
- 新しい通知を捨てる(古い3枚を守る)
- 古い通知を捨てる(新しい3枚を見せる)
僕は2を選びます。理由は単純で、**ユーザーがいちばん知りたいのは「たった今やった操作の結果」**だからです。3秒前の「コピーしました」より、今押した「保存に失敗しました」のほうが大事。だから新しいものを優先し、あふれたら古い方から静かに退場させます。
コードでは配列の末尾に足して、先頭から切り捨てるだけです。
// 新しい通知を末尾に足し、上限を超えたら先頭(古い方)から捨てる
const next = [...current, newToast].slice(-MAX_VISIBLE);
slice(-3) は「末尾から3つ」を取り出す書き方です。[a, b, c, d] なら [b, c, d] になり、いちばん古い a が消えます。たった1行ですが、これが「古いものを捨てる勇気」の正体です。表示順は、画面では新しいものを上に積むか下に積むかを後でCSSで決めます。
自動消去とホバー一時停止:残り時間を覚えておく
自動で消えるトーストには、地味だけど大事な落とし穴があります。ユーザーが読んでいる最中に消える問題です。
長い文面や、英語から翻訳した冗長な文は、5秒では読み切れないことがあります。マウスを乗せた人は「読もうとしている」サインなので、その間はタイマーを止めるべきです。キーボードだけで操作する人のために、フォーカスが当たっている間も止めます。
ここで素朴に実装すると、もう一つ罠を踏みます。ホバーを解除した瞬間にタイマーが最初の5秒に戻ってしまうんです。3秒読んで一瞬マウスを外したら、また5秒待たされる。これは残り時間を覚えていないせいです。
対策は、setTimeoutを開始した時刻と残り時間をuseRefに持つこと。止めるときに「経過した分」を残り時間から引いておけば、再開時は残りだけ数えます。
const remainingMs = useRef(durationMs); // 残り時間を覚えておく箱
const startedAt = useRef<number | null>(null);
const timerId = useRef<number | null>(null);
useEffect(() => {
if (durationMs <= 0 || paused) return; // 0以下や一時停止中はタイマーを張らない
startedAt.current = Date.now();
timerId.current = window.setTimeout(() => onDismiss(id), remainingMs.current);
return () => {
if (timerId.current !== null) window.clearTimeout(timerId.current);
// 止めた瞬間に、経過した分を残り時間から引く → 次回は残りだけ数える
if (startedAt.current !== null) {
remainingMs.current -= Date.now() - startedAt.current;
}
};
}, [durationMs, paused, id, onDismiss]);
paused が true に変わるとクリーンアップ関数が走り、経過分を差し引きます。false に戻るとuseEffectが再実行され、残り時間だけで新しいタイマーを張る。これで「3秒読んでマウスを外したら、残り2秒で消える」が実現します。WAI-ARIAのPause, Stop, Hide(WCAG 2.2)も、自動で動く・消えるUIには停止手段を求めています。閉じるボタンと一時停止は、おまけではなく要件です。
重複のまとめ:同じ通知は1枚に数える
冒頭の「緑が7枚」事故の犯人がこれです。同じ通知が来るたびに新しい1枚を足してしまう問題。
保存ボタンの連打、リトライ、WebSocketからの同種イベント。同じ文面が短時間に何度も飛んでくる場面は意外と多い。これを全部別カードにすると、画面はあっという間に荒れます。
解決は宅配便の不在票と同じ発想です。同じ荷物が3回来ても、ポストに3枚入れるのではなく「3回お伺いしました」と1枚にまとめる。トーストも、同じ内容なら新規追加せず、既存カードのカウンタを増やす。
判定キーは「種類(tone)+タイトル+本文」をつないだ文字列で十分です。
function dedupeKey(input: ToastInput) {
return `${input.tone ?? "info"}::${input.title}::${input.description ?? ""}`;
}
// showToast の中で:同じキーが既にあれば count を増やしてタイマーをリセット
setToasts((current) => {
const idx = current.findIndex((t) => t.key === dedupeKey(input));
if (idx !== -1) {
const updated = [...current];
updated[idx] = { ...updated[idx], count: updated[idx].count + 1, createdAt: Date.now() };
return updated;
}
return [...current, makeToast(input)].slice(-MAX_VISIBLE);
});
カードのタイトル横に count > 1 のとき ×3 のように出せば、ユーザーは「何度も起きている」と一目で分かります。createdAt を更新しているのは、まとめた瞬間に新しい通知として扱い、表示時間をリセットするためです。
コピペで動くキュー管理つきトースト
ここまでの4ルールを1つのToastProviderに集約した、動くコードです。依存はReactだけ。Vite、Next.jsのクライアントコンポーネント、AstroのReact islandで動きます。Next.js App Routerではファイル先頭に"use client";を足してください。
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
type ToastTone = "success" | "info" | "warning" | "error";
type ToastInput = {
title: string;
description?: string;
tone?: ToastTone;
durationMs?: number;
};
type ToastItem = {
id: string;
key: string; // 重複まとめ用の判定キー
title: string;
description: string;
tone: ToastTone;
durationMs: number;
count: number;
createdAt: number;
};
type ToastContextValue = {
showToast: (input: ToastInput) => string;
dismissToast: (id: string) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
const MAX_VISIBLE = 3; // 同時表示の上限
const DEFAULT_DURATION = 5000; // 既定の自動消去(ミリ秒)
function dedupeKey(input: ToastInput) {
return `${input.tone ?? "info"}::${input.title}::${input.description ?? ""}`;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((t) => t.id !== id));
}, []);
const showToast = useCallback((input: ToastInput) => {
const key = dedupeKey(input);
let returnedId = "";
setToasts((current) => {
// 同じ内容が既にあれば、新規追加せずカウンタを増やしてタイマーをリセット
const idx = current.findIndex((t) => t.key === key);
if (idx !== -1) {
returnedId = current[idx].id;
const updated = [...current];
updated[idx] = {
...updated[idx],
count: updated[idx].count + 1,
createdAt: Date.now(),
};
return updated;
}
// 新規はキューの末尾へ。上限を超えたら古い方から捨てる
const id = crypto.randomUUID();
returnedId = id;
const next: ToastItem = {
id,
key,
title: input.title,
description: input.description ?? "",
tone: input.tone ?? "info",
durationMs: input.durationMs ?? DEFAULT_DURATION,
count: 1,
createdAt: Date.now(),
};
return [...current, next].slice(-MAX_VISIBLE);
});
return returnedId;
}, []);
const value = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismissToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) throw new Error("useToast は ToastProvider の中で使ってください");
return context;
}
function ToastViewport({
toasts,
onDismiss,
}: {
toasts: ToastItem[];
onDismiss: (id: string) => void;
}) {
// 領域は常時描画する。後からDOMに足すと最初の1件が読み上げられないため
return (
<div className="toast-viewport" role="region" aria-label="通知">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
}
function ToastCard({
toast,
onDismiss,
}: {
toast: ToastItem;
onDismiss: (id: string) => void;
}) {
const [paused, setPaused] = useState(false);
const remainingMs = useRef(toast.durationMs);
const startedAt = useRef<number | null>(null);
const timerId = useRef<number | null>(null);
// まとめ直し(createdAt更新)で残り時間も初期化する
useEffect(() => {
remainingMs.current = toast.durationMs;
}, [toast.createdAt, toast.durationMs]);
useEffect(() => {
if (toast.durationMs <= 0 || paused) return;
startedAt.current = Date.now();
timerId.current = window.setTimeout(() => onDismiss(toast.id), remainingMs.current);
return () => {
if (timerId.current !== null) window.clearTimeout(timerId.current);
if (startedAt.current !== null) {
remainingMs.current -= Date.now() - startedAt.current; // 経過分を残り時間から引く
}
};
}, [toast.durationMs, toast.id, toast.createdAt, paused, onDismiss]);
// 緊急のエラーだけ割り込みの強い alert、それ以外は控えめな status
const role = toast.tone === "error" ? "alert" : "status";
return (
<section
className={`toast-card toast-card--${toast.tone}`}
role={role}
aria-atomic="true"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div className="toast-card__content">
<strong className="toast-card__title">
{toast.title}
{toast.count > 1 ? <span className="toast-card__count"> ×{toast.count}</span> : null}
</strong>
{toast.description ? <p>{toast.description}</p> : null}
</div>
<button
type="button"
className="toast-card__close"
aria-label={`${toast.title}を閉じる`}
onClick={() => onDismiss(toast.id)}
>
×
</button>
</section>
);
}
使う側はAppをToastProviderで包み、処理結果でshowToastを呼ぶだけ。連打しても重複はまとまり、4枚目以降は古いものから消えます。
import { ToastProvider, useToast } from "./ToastProvider";
import "./toast.css";
function SaveButton() {
const { showToast } = useToast();
async function handleSave() {
try {
await new Promise((r) => window.setTimeout(r, 500));
showToast({ tone: "success", title: "保存しました" });
} catch {
// エラーは長めに出し、緊急通知として割り込ませる
showToast({
tone: "error",
title: "保存に失敗しました",
description: "通信を確認してもう一度お試しください。",
durationMs: 8000,
});
}
}
return <button onClick={handleSave}>保存する</button>;
}
export default function App() {
return (
<ToastProvider>
<main>
<h1>設定</h1>
<SaveButton />
</main>
</ToastProvider>
);
}
最低限のCSSも置いておきます。pointer-events: none を領域に、auto をカードに付けるのがコツ。これで通知の隙間ごしに背後のボタンを押せます。位置はモバイルでenv(safe-area-inset-*)を見て、ノッチやホームバーを避けます。
.toast-viewport {
position: fixed;
top: max(16px, env(safe-area-inset-top));
right: max(16px, env(safe-area-inset-right));
z-index: 1000;
display: grid;
gap: 10px;
width: min(380px, calc(100vw - 32px));
pointer-events: none; /* 隙間ごしに背後を操作できる */
}
.toast-card {
pointer-events: auto;
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
padding: 14px 16px;
border: 1px solid #d8dee8;
border-left-width: 5px;
border-radius: 8px;
background: #fff;
color: #172033;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
animation: toast-in 180ms ease-out;
}
.toast-card--success { border-left-color: #15803d; }
.toast-card--info { border-left-color: #2563eb; }
.toast-card--warning { border-left-color: #b45309; }
.toast-card--error { border-left-color: #b91c1c; }
.toast-card__count { color: #526071; font-size: 0.8rem; }
.toast-card p { margin: 4px 0 0; color: #46536a; font-size: 0.875rem; line-height: 1.5; }
.toast-card__close {
min-width: 32px;
min-height: 32px;
border: 0;
border-radius: 6px;
background: transparent;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 640px) {
.toast-viewport { left: 16px; right: 16px; width: auto; }
}
@media (prefers-reduced-motion: reduce) {
.toast-card { animation: none; } /* 動きを減らす設定では入場アニメを止める */
}
aria-liveとrole:支援技術に「ちょうどよく」伝える
ここが見た目では分からない難所です。同じトーストでも、スクリーンリーダーにどう読ませるかで印象がまるで変わります。
ポイントはroleの出し分け。role="status"は内部的にaria-live="polite"、role="alert"はaria-live="assertive"として扱われます。politeは「今読んでいるのが終わったら読む」、assertiveは「今すぐ割り込んで読む」。詳しくはMDNのstatus roleとalert roleが一次情報です。
僕が踏んだ失敗は、全部をalertにしたこと。「コピーしました」までガンガン割り込んで読み上げられ、スクリーンリーダー利用者の作業を毎回中断させていました。今はこう分けています。
success/info/warning→status(控えめ。作業の邪魔をしない)errorで即時対応が要るものだけ →alert(割り込んでよい)
もう一つの落とし穴が、ライブ領域を後からDOMに足すこと。通知が来てから<div aria-live>を挿入すると、その最初の1件が読まれないことがあります。スクリーンリーダーは「既にある領域の変化」を見張るのが得意だから。だから上のコードではToastViewportを常時描画し、カードの追加を「変化」として拾わせています。aria-atomic="true"は、カードの中身を部分的にではなく丸ごと読ませる指定です。
通知UIに限らないアクセシビリティの全体像はClaude Codeでアクセシビリティ対応を実装するにまとめてあります。トーストはその一部品として捉えると設計がぶれません。
僕がキュー管理でやらかした失敗3つ
正直に書きます。最初のトーストは穴だらけでした。
ひとつ目は、古い通知から消したこと。 上限3枚で、あふれたら先頭(最新)を捨てる実装にしていました。結果、連打すると最新のエラーが一瞬で消え、3秒前の成功通知だけが残る。逆です。捨てるなら古い方から。slice(-MAX_VISIBLE)に直したら直りました。
ふたつ目は、ホバー解除でタイマーが巻き戻ったこと。 残り時間を覚えていなかったので、マウスを乗せて外すたびに5秒に戻る。長文を読もうとマウスを動かすたびに寿命が延びて、いつまでも消えない通知ができました。useRefに残り時間を持たせて解決しています。
みっつ目は、重複をまとめなかったこと。 これが冒頭の「緑7枚」事故です。同じ内容を毎回新規追加していたら、リトライ処理で画面が埋まった。dedupeKeyで同一判定してcountを足す方式に変えてから、画面が荒れなくなりました。
3つとも、1枚のカードを見ているだけでは気づけません。複数同時・連打・支援技術という「混雑時」を試して初めて出るバグです。
Claude Codeにキュー管理まで頼むプロンプト
「トースト作って」だと見た目だけが返ってきます。混雑時の挙動まで仕様に書いて渡すと、最初からキュー管理込みの実装になります。
React + TypeScript でトースト通知を ToastProvider.tsx と toast.css に実装してください。
キュー管理の要件:
- 同時表示は最大3件。あふれたら古い方(先頭)から捨てる
- 自動消去あり。既定5秒、errorは長め
- ホバーとフォーカス中はタイマーを止め、解除後は残り時間で再開(useRefで保持)
- 同じ tone+title+description の通知は新規追加せず count を増やして1枚にまとめる
アクセシビリティの要件:
- success/info/warning は role="status"、error で緊急なものだけ role="alert"
- ライブ領域は常時描画し、後からDOMに足さない
- aria-atomic="true"、閉じるボタンに aria-label
- prefers-reduced-motion で入場アニメを無効化
検証:
- npm run typecheck / npm run lint
- 連打での重複まとめ、4件目で古いものが消えるか、ホバー解除後に残り時間で消えるか、
スクリーンリーダーで success が割り込まないかを確認
このチェック観点をCLAUDE.mdベストプラクティスやClaude Codeのレビュー用ワークフローに書いておくと、毎回同じ品質で見直せます。
よくある質問
Q. 同時表示は何件までが適切ですか。 A. 2〜3件が現実的です。4件以上を常時見せると、ユーザーは現在の操作画面を見失います。多くの通知が出るアプリなら、上限を3件にして「通知センター」を別に用意し、履歴はそちらで見せる構成にします。
Q. 古い通知と新しい通知、どちらを残すべきですか。
A. 新しい方です。ユーザーがいちばん知りたいのは「たった今の操作結果」だからです。slice(-MAX_VISIBLE)で末尾(新しい方)を残し、あふれた古い通知を捨てます。
Q. 重複のまとめキーは何を使えばいいですか。
A. 「tone+title+description」をつないだ文字列で十分です。送信元を区別したいときは、呼び出し側からdedupeIdのような明示キーを渡せるよう拡張すると確実です。
Q. 自動消去は何秒が目安ですか。 A. 成功や情報は5秒前後、エラーは8秒以上が目安です。ただし時間に頼り切らず、必ず閉じるボタンとホバー停止を併せて、ユーザーが読む時間を自分で延ばせるようにします。
Q. statusとalertはどう使い分けますか。
A. 作業を止めなくてよいものはstatus、今すぐ対応が要るものだけalertです。alertは読み上げに割り込むので、成功通知やコピー完了に使うと支援技術の利用者を疲れさせます。
まとめ:トーストは「店主」を作る仕事
トースト通知のコードで難しいのは、カード1枚のCSSではありません。何枚も来たときに、並べ、間引き、まとめ、ちょうどよく消す——その采配を握る店主、つまりキューの管理者をどう作るかです。
今日の要点はシンプルです。上限を決めて古いものから捨てる。残り時間を覚えてホバー中だけ止める。同じ通知はカウンタでまとめる。読み上げはstatusとalertで出し分け、領域は常時置く。この4つをProviderの1か所に集約すれば、連打されても画面は荒れません。
Claude Codeに頼むときも、「通知を作って」ではなく「混雑したらどう振る舞うか」まで仕様に書く。そこが見た目だけの実装と、本番で耐える実装の分かれ目です。
通知の次は、操作を止めるUIや読み込み中の見せ方も気になるはずです。Claude Codeでモーダルダイアログを安全に実装するやClaude Codeで無限スクロールを本番実装するも同じ「混雑時に崩れない」発想で書いています。UI実装・レビュー・検証のプロンプトをまとめて持っておきたいならClaudeCodeLabの教材一覧が近道です。
この記事で紹介した内容を実際に試した結果
手元のReact検証環境で、保存ボタンを10連打して重複が1枚に×10でまとまること、4件目で最古のカードが消えること、ホバー中はタイマーが止まり外すと残り時間で消えること、prefers-reduced-motionで入場アニメが消えることを確認しました。createdAtを更新したときにremainingMsを初期化し忘れると、まとめた通知が即消えするバグが出たので、その依存もuseEffectに入れてあります。実プロダクトではこの上に、Playwrightで連打と4件目の消去をテストし、NVDA・VoiceOverでsuccessが割り込まないかを手動確認するのが現実的でした。賢いUIを足すより、混雑時に崩れない店主を先に作る。これがいちばん事故が減る、というのが今の実感です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。