背景

正直なところ、最初は静的なエラー表示で問題ないと思っていましたが、実際に使ってみるとユーザー体験の悪さに気づきました。

エラーメッセージの表示方法が静的な赤枠表示だったため、ユーザー体験が悪かったです。

重大度に応じた色分け、自動消去、アクションボタン対応のToast通知システムに改善する必要がありました。

こんな人におすすめ

この記事は以下のような方に役立ちます。

  • ReactでToast通知システムを実装したい方
  • 重大度別の色分けや自動消去タイマーを実装したい方
  • useRefによるタイマー管理やメモリリーク防止を学びたい方
  • アクセシビリティ(WCAG AA準拠)を考慮したUIを実装したい方
  • 既存コードの後方互換性を維持しながら機能を追加したい方

目次

要件

flowchart LR
    subgraph 重大度別の振る舞い
        S[success/info]
        W[warning]
        E[error]
    end

    S -->|3秒後自動消去| A[Toast表示]
    W -->|5秒後自動消去| A
    E -->|手動クローズのみ| A

    A -->|×ボタン| C[クローズ]
    A -->|Escapeキー| C
    A -->|アクションボタン| C

    style S fill:#d1fae5
    style W fill:#fef3c7
    style E fill:#fee2e2
    style A fill:#e0e7ff
    style C fill:#f3f4f6
  • 重大度別の色分け(success/info/warning/error)
  • 自動消去タイマー(success/info: 3秒、warning: 5秒、error: 手動のみ)
  • 複数Toast同時表示(最大3件)
  • アクションボタン付きToast(「再試行」など)
  • アクセシビリティ対応(WCAG AA準拠)
  • 後方互換性の維持

実装方針

Phase 1-4: 基盤構築

  1. src/types/toast.ts - 型定義(ToastSeverity, ToastItem, ShowToastOptions)
  2. src/hooks/useToast.ts - カスタムフック(複数Toast管理、タイマー、重複防止)
  3. src/components/Toast.tsx - ToastContainerコンポーネント
  4. index.css - Tailwind CSSで重大度別スタイル

Phase 5-8: 移行

  1. App.tsx - Toast統合、エラー表示移行
  2. FileUploadStep - showToast経由のエラー表示
  3. StepRenderer - showToast型拡張
  4. ResultStep - ダウンロードエラーのToast化
graph TD
    subgraph フロントエンド層
        A[Reactコンポーネント]
        B[Toast.tsx<br/>ToastContainer]
        C[useToastカスタムフック]
    end

    subgraph 状態管理
        D[useState<br/>Toast配列]
        E[useRef<br/>タイマー管理]
        F[useCallback<br/>関数メモ化]
    end

    subgraph 型定義
        G[ToastItem<br/>severity, message]
        H[ShowToastOptions<br/>dedupe, duration]
    end

    A -->|showToast呼び出し| C
    C -->|状態更新| D
    C -->|タイマー設定| E
    D -->|レンダリング| B
    B -->|自動消去| E
    B -->|アクセシビリティ| B

    style A fill:#e0e7ff
    style B fill:#fef3c7
    style C fill:#d1fae5
    style D fill:#fce7f3
    style E fill:#ddd6fe

ポイント

この実装で押さえたポイントを解説します。

1. useRefでタイマー管理(メモリリーク防止)

stateDiagram-v2
    [*] --> Toast作成: showToast呼び出し
    Toast作成 --> タイマー設定: setTimeout実行
    タイマー設定 --> タイマー待機中: timerRefsにIDを保存
    タイマー待機中 --> 自動消去: タイムアウト
    タイマー待機中 --> 手動クローズ: ×ボタン/Escape
    自動消去 --> [*]
    手動クローズ --> [*]

    note right of タイマー待機中
        useRefで管理
        メモリリーク防止
    end note

    note right of 手動クローズ
        clearTimeoutで解除
    end note

useRefでタイマーIDを管理し、コンポーネントのアンマウント時に確実にクリーンアップすることで、メモリリークを防ぎます。

const timerRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());

// クリーンアップ
useEffect(() => {
  return () => {
    timerRefs.current.forEach((timer) => clearTimeout(timer));
    timerRefs.current.clear();
  };
}, []);

2. 重複Toast防止オプション

flowchart TD
    A[showToast呼び出し] --> B{dedupeオプション}
    B -->|false| D[新規Toastを追加]
    B -->|true| C{同一メッセージが<br/>存在する?}

    C -->|はい| E[スキップ<br/>既存Toastを維持]
    C -->|いいえ| F{最大3件に達した?}

    F -->|はい| G[最古のToastを削除]
    F -->|いいえ| D
    G --> D

    D --> H[Toast配列を更新]

    style E fill:#fef3c7
    style H fill:#d1fae5

重複するメッセージのToastを防ぐことで、ユーザー体験を向上させます。

const showToast = useCallback((message: string, options: ShowToastOptions = {}) => {
  if (options.dedupe) {
    const key = options.dedupeKey ?? message;
    const exists = prev.some((t) => t.message === key);
    if (exists) return prev;
  }
  return [...prev, newToast].slice(-MAX_TOASTS);
}, []);

3. アクセシビリティ対応

sequenceDiagram
    participant U as ユーザー
    participant T as Toast
    participant SR as スクリーンリーダー

    Note over T,SR: errorの場合(aria-live="assertive")
    U->>T: エラーが発生
    T->>SR: role="alert"<br/>aria-live="assertive"
    SR->>U: 🔊 即座に読み上げ<br/>「エラー:ファイルのアップロードに失敗しました」

    Note over T,SR: infoの場合(aria-live="polite")
    U->>T: 通知が表示
    T->>SR: role="alert"<br/>aria-live="polite"
    SR->>U: 🔊 適宜読み上げ<br/>「保存が完了しました」

    Note over U,T: キーボード操作
    U->>T: Escapeキー押下
    T->>T: onCloseハンドラー実行
    T->>U: Toastが閉じる

WCAG AA準拠のアクセシビリティ対応を実装します。

{ if (e.key === 'Escape') onClose(id); }} > ```

4. 後方互換性

graph LR
    subgraph old_api["旧API 引き続き動作"]
        A1[toast.showToast<br/>メッセージ]
        A2[toast.closeToast<br/>引数なし]
    end

    subgraph new_api["新API 拡張機能"]
        B1[toast.showToast<br/>エラー<br/>オプション指定]
        B2[個別クローズ<br/>onCloseハンドラー]
    end

    A1 -->|後方互換| B1
    A2 -->|後方互換| B2

    style A1 fill:#e5e7eb
    style A2 fill:#e5e7eb
    style B1 fill:#d1fae5
    style B2 fill:#d1fae5

既存コードを壊さずに新機能を追加します。

// 新API
toast.showToast('エラー', { severity: 'error', dedupe: true });

// 旧API(引き続き動作)
toast.showToast('メッセージ');
toast.closeToast(); // 引数なしで全クリア

この改善の良かった点

実際にこの改善を適用してみて、いくつかの利点がありました。

ユーザー体験の向上

静的な赤枠表示から動的なToast通知に変更したことで、ユーザーからの不満が減りました。

自動消去タイマーのおかげで、エラー表示が画面に残り続けることがなくなり、ユーザーが手動で閉じる手間が省けました。

アクセシビリティの向上

WCAG AA準拠のアクセシビリティ対応を実装したことで、スクリーンリーダーユーザーにも適切に情報が伝達されるようになりました。

開発効率の向上

重複Toast防止オプションのおかげで、同じエラーが何度も表示される問題が解消されました。

学び

  • 段階的移行の重要性: 後方互換性を維持しながら新機能を追加することで、既存コードを壊さずに移行できた
  • useRefでタイマー管理: setTimeoutのIDをuseRefで管理し、クリーンアップ関数で確実に解除することでメモリリークを防止
  • useCallbackの活用: showToast, closeToastをuseCallbackでメモ化し、不要な再レンダリングを防止
  • アクセシビリティは設計段階から: role=“alert”、aria-live、Escapeキー対応を最初から組み込むことで、後付けよりも自然な実装になった
  • テストファースト思考: 既存テストを更新しながら実装することで、後方互換性の破壊を早期に検出できた

確認手順

  1. エラー発生時にToastが右上(モバイルは下部)に表示される
  2. 重大度に応じた色分けがされている
  3. success/info: 3秒、warning: 5秒で自動消去
  4. errorはアクションボタンまたは×ボタンで手動クローズ
  5. 複数エラー時に最大3件まで表示
  6. Escapeキーで閉じられる
  7. スクリーンリーダーで読み上げられる

まとめ

ReactでToast通知システムを実装する方法を解説しました。

実装のポイント:

  1. useRefでタイマー管理: setTimeoutのIDをuseRefで管理し、クリーンアップ関数で確実に解除することでメモリリークを防止
  2. 重複Toast防止: dedupeオプションで同じメッセージの重複表示を回避
  3. アクセシビリティ対応: role=“alert”、aria-live、Escapeキー対応を最初から組み込む
  4. 後方互換性維持: 既存APIを壊さずに新機能を追加

得られる効果:

  • ユーザー体験の向上(自動消去、重大度別色分け)
  • アクセシビリティの向上(WCAG AA準拠)
  • 開発効率の向上(重複防止、型安全)

個人的に、この実装を通じて学んだことは大きかったです。特に、useRefによるタイマー管理とアクセシビリティ対応を最初から組み込む重要性を実感しました。

同じようなToast通知システムの実装を検討している方の参考になれば幸いです。


FAQ:よくある質問

Q1: Toast通知システムを実装するのにどのくらい時間がかかりますか?

A: 基本的な実装で1〜2日、テストと調整を含めて3〜5日程度です。内訳は以下の通りです:

  • 型定義とカスタムフック: 半日〜1日
  • ToastContainerコンポーネント: 半日
  • スタイリング(Tailwind CSS): 半日
  • アクセシビリティ対応: 1日
  • 既存コードへの移行: 1日

Q2: メモリリークを防ぐにはどうすればよいですか?

A: 以下の対策が有効です:

  1. useRefでタイマーIDを管理: setTimeoutの戻り値をuseRefで保持
  2. クリーンアップ関数の実装: useEffectの返り値でclearTimeoutを実行
  3. Mapでの一元管理: 複数のタイマーをMapで管理し、まとめてクリア

この記事の「1. useRefでタイマー管理」セクションで具体的な実装コードを紹介しています。

Q3: アクセシビリティ対応は必須ですか?

A: WCAG AA準拠を目指す場合、アクセシビリティ対応は強く推奨されます。最低限の実装として以下が挙げられます:

  • role="alert": スクリーンリーダーに通知を伝達
  • aria-live: 重要度に応じて “assertive” または “polite” を設定
  • tabIndex={0}: キーボードフォーカスを可能にする
  • onKeyDownでEscapeキー対応: キーボードユーザーに閉じる手段を提供

Q4: 既存の通知システムから移行するのは難しいですか?

A: 後方互換性を維持すればスムーズに移行できます。以下のステップで進めることを推奨します:

  1. Phase 1: 新しいToastシステムを並行実装(既存システムは残す)
  2. Phase 2: 新しいコンポーネントを徐々に適用
  3. Phase 3: 既存の通知呼び出しを新しいAPIに置き換え
  4. Phase 4: 古いコードを削除

この記事では「後方互換性」セクションで具体的な実装例を紹介しています。

Q5: Toastの表示位置はカスタマイズできますか?

A: はい、Tailwind CSSのクラス調整で自由にカスタマイズ可能です。一般的な配置例:

  • 右上: fixed top-4 right-4(デスクトップ推奨)
  • 右下: fixed bottom-4 right-4(通知アプリ推奨)
  • 上部中央: fixed top-4 left-1/2 transform -translate-x-1/2
  • モバイル: 画面下部の固定配置が推奨

Q6: 複数のToastが同時に出た場合の表示順序はどうなりますか?

A: 通常は**新しい順(上から新しい、下が古い)**で表示されます。実装方法として:

  1. 配列の末尾に追加: slice(-MAX_TOASTS)で最大件数を制限
  2. Stack方向の調整: CSSのflex-directionで制御
  3. アニメーション: 新しいToastが上からスライドイン

Q7: 自動消去のタイミングは変更できますか?

A: はい、以下の基準で調整可能です:

  • success/info: 3秒(軽い通知)
  • warning: 5秒(注意喚起)
  • error: 手動のみ(重要なエラー)

ユーザーがホバーしている間は自動消去を延期する実装も推奨されます。

Q8: React以外のフレームワーク(VueやSvelte)でも使えますか?

A: この記事で紹介している概念は一般的ですが、実装はReact特有のものです。ただし、以下の概念は他のフレームームワークでも適用可能です:

  • タイマー管理: ライフサイクルフックでクリーンアップ
  • 状態管理: フレームワークのリアクティブシステム
  • アクセシビリティ: HTML標準のARIA属性(フレームワークに依存しない)

外部リンク(公式ドキュメント):