T-CREATOR

Web Components イベント設計チート:`CustomEvent`/`composed`/`bubbles` 実例集

Web Components イベント設計チート:`CustomEvent`/`composed`/`bubbles` 実例集

Web Components でカスタムイベントを設計する際、CustomEvent のオプション設定で迷った経験はありませんか。composedbubbles の違いを理解していないと、Shadow DOM の境界を越えられず、イベントが親コンポーネントに届かないという問題に直面します。

この記事では、Web Components におけるイベント設計の要となる CustomEventcomposedbubbles の 3 つのキーワードに絞って、実例を交えながら解説していきます。イベント伝播の仕組みを図解とコードで理解し、実務で使えるパターンを身につけましょう。

イベント設計早見表

まず、CustomEvent のオプションとその効果を表にまとめました。実装時のクイックリファレンスとしてご活用ください。

#プロパティデフォルト値説明使用シーン
1bubblesbooleanfalseイベントが DOM ツリーを上方向に伝播するか親要素でイベントをキャッチしたい場合
2composedbooleanfalseイベントが Shadow DOM の境界を越えるかShadow DOM 外の要素でイベントを検知したい場合
3cancelablebooleanfalsepreventDefault() でキャンセル可能かデフォルト動作を防ぎたい場合
4detailanynullイベントに付随するカスタムデータイベントと一緒にデータを渡したい場合

組み合わせパターン早見表

#bubblescomposed伝播範囲用途例
1falsefalseShadow DOM 内のみコンポーネント内部の状態変更
2truefalseShadow DOM 内で上方向Shadow 内の親要素への通知
3falsetrueShadow DOM 境界を越えるが上昇しない外部への通知(バブリング不要)
4truetrue全範囲(境界を越えて上昇)グローバルイベント(クリック、入力など)

この早見表を見ながら、以下の詳細解説を読み進めていただくと理解が深まります。

背景

Web Components とイベントシステム

Web Components は、カスタム要素、Shadow DOM、HTML テンプレートという 3 つの技術で構成される標準仕様です。特に Shadow DOM は、スタイルや DOM ツリーをカプセル化できる強力な機能ですが、同時にイベントの伝播にも影響を与えます。

通常の DOM では、子要素で発生したイベントは親要素へと自動的に伝播(バブリング)していきます。しかし、Shadow DOM が絡むと、この挙動が変わるのです。

以下の図は、通常の DOM と Shadow DOM におけるイベント伝播の違いを示しています。

mermaidflowchart TB
  subgraph Light["Light DOM(通常の DOM)"]
    L1["document"]
    L2["body"]
    L3["div"]
    L4["button"]
  end

  subgraph Shadow["Shadow DOM 環境"]
    S1["document"]
    S2["body"]
    S3["custom-element<br/>(Shadow Host)"]
    S4["#shadow-root"]
    S5["button"]
  end

  L4 -->|"イベント<br/>バブリング"| L3
  L3 -->|"イベント<br/>バブリング"| L2
  L2 -->|"イベント<br/>バブリング"| L1

  S5 -.->|"境界で<br/>ブロック"| S4
  S4 -.->|"composed: true<br/>なら通過"| S3
  S3 -->|"イベント<br/>バブリング"| S2
  S2 -->|"イベント<br/>バブリング"| S1

通常の DOM ではイベントが自然に上昇しますが、Shadow DOM では #shadow-root という境界が存在します。この境界を越えるかどうかを制御するのが composed プロパティです。

イベント伝播の 2 つの段階

JavaScript のイベントシステムには、3 つのフェーズがあります。

  1. キャプチャフェーズ:イベントが document からターゲット要素へ下降
  2. ターゲットフェーズ:イベントがターゲット要素に到達
  3. バブリングフェーズ:イベントがターゲット要素から document へ上昇

bubbles プロパティは、このバブリングフェーズでイベントが伝播するかを制御します。composed プロパティは、Shadow DOM の境界を越える際に適用されるのです。

課題

Shadow DOM 境界でイベントが届かない問題

Web Components を初めて実装する際、最も頻繁に遭遇するのが「イベントが親に届かない」という問題です。これは CustomEvent のデフォルト設定が原因で発生します。

以下は、よくある失敗例です。

typescript// custom-button.ts(失敗例)
class CustomButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <button>クリック</button>
    `;

    const button = this.shadowRoot.querySelector('button');
    button.addEventListener('click', () => {
      // デフォルト設定のイベント
      const event = new CustomEvent('custom-click');
      this.dispatchEvent(event);
    });
  }
}

このコンポーネントを使用する側で、以下のようにイベントをキャッチしようとします。

typescript// 親コンポーネントまたは HTML
const customButton =
  document.querySelector('custom-button');
customButton.addEventListener('custom-click', () => {
  console.log('イベントが発火!'); // 実行されない
});

この実装では、イベントが親要素に届きません。なぜなら、CustomEvent のデフォルト設定では bubbles: falsecomposed: false となっているからです。

次の図は、この問題を視覚化したものです。

mermaidflowchart TB
  Doc["document"]
  Body["body"]
  Host["&lt;custom-button&gt;<br/>(Shadow Host)"]
  Root["#shadow-root"]
  Btn["&lt;button&gt;"]

  Btn -->|"1. click"| Btn
  Btn -.->|"2. CustomEvent<br/>dispatched"| Host
  Root -.->|"❌ composed: false<br/>境界で停止"| Host

  style Root fill:#ffcccc
  style Host fill:#ffcccc

イベントは Shadow Root の境界で止まってしまい、外部のリスナーには届きません。この問題を解決するには、bubblescomposed の両方を理解する必要があります。

イベント設計の複雑さ

もう 1 つの課題は、「どのイベントにどの設定を使うべきか」という判断の難しさです。すべてのイベントに bubbles: true, composed: true を設定すれば動作しますが、これはカプセル化の原則に反します。

例えば、以下のような状況を考えてみましょう。

#シチュエーション望ましい動作設定の組み合わせ
1ユーザーがボタンをクリックグローバルに検知したいbubbles: true, composed: true
2コンポーネント内部の状態変更外部に漏らしたくないbubbles: false, composed: false
3フォーム送信イベント親フォーム要素で検知bubbles: true, composed: true
4スロットコンテンツの変更Shadow 内の親のみbubbles: true, composed: false

このように、状況に応じて適切な設定を選択する必要がありますが、その判断基準が明確でないと実装に迷ってしまいます。

解決策

bubbles の役割と使い分け

bubbles プロパティは、イベントが DOM ツリーを上方向に伝播するかを制御します。true に設定すると、イベントは親要素、その親要素、さらにその親要素へと伝播していきます。

以下のコードは、bubbles の違いを示しています。

typescript// bubbles: false の場合
const nonBubblingEvent = new CustomEvent('non-bubble', {
  bubbles: false,
  detail: { message: 'このイベントは伝播しない' },
});
typescript// bubbles: true の場合
const bubblingEvent = new CustomEvent('bubble', {
  bubbles: true,
  detail: { message: 'このイベントは伝播する' },
});

bubbles: true を使うべきシーンは以下の通りです。

  • ユーザーインタラクション(クリック、入力など)を親要素で処理したい
  • イベント委譲(Event Delegation)パターンを使いたい
  • 複数の親要素でイベントを監視したい

逆に、bubbles: false が適切なシーンは以下です。

  • コンポーネント内部のみで完結する処理
  • 特定の要素でのみキャッチすべきイベント
  • パフォーマンスを最適化したい場合(不要な伝播を防ぐ)

composed の役割と使い分け

composed プロパティは、イベントが Shadow DOM の境界を越えるかを制御します。これは Web Components 特有の設定で、通常の DOM イベントにはない概念です。

以下の図は、composed の有無による動作の違いを示しています。

mermaidflowchart LR
  subgraph Inside["Shadow DOM 内部"]
    Elem["イベント発生元"]
  end

  Border["Shadow Root<br/>(境界)"]

  subgraph Outside["Light DOM(外部)"]
    Host["Shadow Host"]
    Parent["親要素"]
  end

  Elem -->|"イベント発火"| Border
  Border -->|"composed: false<br/>❌ ブロック"| BlockedPath[ ]
  Border -->|"composed: true<br/>✅ 通過"| Host
  Host --> Parent

  style Border fill:#ffffcc
  style BlockedPath fill:#ffcccc
  style Host fill:#ccffcc
  style Parent fill:#ccffcc

composed: true を使うべきシーンは以下の通りです。

typescript// ユーザーインタラクションイベント
class InteractiveButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  emitClickEvent() {
    // 外部でキャッチする必要があるイベント
    const event = new CustomEvent('button-clicked', {
      bubbles: true,
      composed: true, // Shadow DOM 境界を越える
      detail: { timestamp: Date.now() },
    });
    this.dispatchEvent(event);
  }
}

composed: false を使うべきシーンは以下です。

typescript// 内部状態の変更イベント
class InternalComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  notifyInternalChange() {
    // Shadow DOM 内部でのみキャッチ
    const event = new CustomEvent('internal-update', {
      bubbles: true,
      composed: false, // カプセル化を維持
      detail: { state: this.internalState },
    });
    this.shadowRoot.dispatchEvent(event);
  }
}

4 つの組み合わせパターン

bubblescomposed の組み合わせにより、4 つの基本パターンが生まれます。以下の表でそれぞれの特徴をまとめました。

#パターン設定伝播範囲使用例
1内部限定bubbles: falsecomposed: falseディスパッチした要素のみデバッグイベント、内部ログ
2Shadow 内バブリングbubbles: truecomposed: falseShadow DOM 内の親要素までスロット変更、内部状態同期
3境界越え単発bubbles: falsecomposed: trueShadow Host まで(上昇なし)初期化完了通知
4グローバル伝播bubbles: truecomposed: trueすべての親要素までクリック、フォーカス、入力

それぞれのパターンを実装例で見ていきましょう。

typescript// パターン 1: 内部限定イベント
const internalEvent = new CustomEvent('debug-log', {
  bubbles: false,
  composed: false,
  detail: {
    level: 'info',
    message: 'Internal state changed',
  },
});
this.dispatchEvent(internalEvent);
typescript// パターン 2: Shadow 内バブリング
const shadowEvent = new CustomEvent('slot-changed', {
  bubbles: true,
  composed: false,
  detail: { slotName: 'header' },
});
this.dispatchEvent(shadowEvent);
typescript// パターン 3: 境界越え単発
const readyEvent = new CustomEvent('component-ready', {
  bubbles: false,
  composed: true,
  detail: { version: '1.0.0' },
});
this.dispatchEvent(readyEvent);
typescript// パターン 4: グローバル伝播
const clickEvent = new CustomEvent('item-selected', {
  bubbles: true,
  composed: true,
  detail: { itemId: 42 },
});
this.dispatchEvent(clickEvent);

これらのパターンを理解することで、目的に応じた適切なイベント設計ができるようになります。

detail によるデータ受け渡し

CustomEventdetail プロパティを使うと、イベントと一緒に任意のデータを渡せます。これにより、イベントリスナー側で必要な情報を取得できます。

typescript// イベント発火側
class DataEmitter extends HTMLElement {
  sendData() {
    const event = new CustomEvent('data-updated', {
      bubbles: true,
      composed: true,
      detail: {
        userId: 123,
        userName: 'Taro Yamada',
        timestamp: new Date().toISOString(),
        metadata: {
          source: 'user-profile',
          action: 'update',
        },
      },
    });
    this.dispatchEvent(event);
  }
}
typescript// イベント受信側
element.addEventListener('data-updated', (event) => {
  // event.detail から情報を取得
  console.log(event.detail.userId); // 123
  console.log(event.detail.userName); // 'Taro Yamada'
  console.log(event.detail.metadata); // { source: ..., action: ... }
});

detail には、オブジェクト、配列、プリミティブ値など、任意の JavaScript 値を設定できます。型安全性を高めたい場合は、TypeScript で型定義を行うと良いでしょう。

具体例

実例 1: カスタムボタンコンポーネント

まず、最も基本的なカスタムボタンコンポーネントを実装します。このボタンは、クリックイベントを外部に伝播させます。

typescript// custom-button.ts
class CustomButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }
typescript  // レンダリング処理
  render() {
    this.shadowRoot.innerHTML = `
      <style>
        button {
          padding: 12px 24px;
          background: #007bff;
          color: white;
          border: none;
          border-radius: 4px;
          cursor: pointer;
          font-size: 16px;
        }
        button:hover {
          background: #0056b3;
        }
      </style>
      <button>
        <slot>ボタン</slot>
      </button>
    `;
  }
typescript  // イベントリスナー設定
  setupEventListeners() {
    const button = this.shadowRoot.querySelector('button');

    button.addEventListener('click', (e) => {
      // カスタムイベントを発火
      const customEvent = new CustomEvent('custom-click', {
        bubbles: true,      // DOM ツリーを上昇
        composed: true,     // Shadow DOM 境界を越える
        detail: {
          originalEvent: e,
          timestamp: Date.now(),
          buttonText: button.textContent
        }
      });

      this.dispatchEvent(customEvent);
    });
  }
}

customElements.define('custom-button', CustomButton);

このボタンコンポーネントを使う側では、以下のようにイベントをキャッチできます。

html<!-- HTML での使用例 -->
<custom-button id="myButton">送信</custom-button>

<script>
  const button = document.getElementById('myButton');

  button.addEventListener('custom-click', (event) => {
    console.log('ボタンがクリックされました!');
    console.log('タイムスタンプ:', event.detail.timestamp);
    console.log('ボタンテキスト:', event.detail.buttonText);
  });
</script>

次の図は、このイベントフローを示しています。

mermaidsequenceDiagram
    participant User as ユーザー
    participant Btn as Shadow内button
    participant CB as custom-button
    participant Doc as document

    User->>Btn: クリック
    Btn->>CB: click イベント
    CB->>CB: CustomEvent 作成<br/>(bubbles: true, composed: true)
    CB->>Doc: custom-click 発火
    Doc->>Doc: イベントリスナー実行
    Doc-->>User: 処理完了

このパターンは、ユーザーインタラクションを外部に通知する最も一般的な実装です。

実例 2: フォーム入力コンポーネント

次に、入力値の変更を通知するフォームコンポーネントを実装します。このコンポーネントは、入力値が変更されるたびにイベントを発火します。

typescript// custom-input.ts
class CustomInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._value = '';
  }

  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }
typescript  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .input-wrapper {
          display: flex;
          flex-direction: column;
          gap: 4px;
        }
        label {
          font-size: 14px;
          font-weight: bold;
          color: #333;
        }
        input {
          padding: 8px 12px;
          border: 1px solid #ccc;
          border-radius: 4px;
          font-size: 16px;
        }
        input:focus {
          outline: none;
          border-color: #007bff;
        }
      </style>
      <div class="input-wrapper">
        <label>${this.getAttribute('label') || 'Input'}</label>
        <input type="text" />
      </div>
    `;
  }
typescript  setupEventListeners() {
    const input = this.shadowRoot.querySelector('input');

    // input イベント(リアルタイム入力)
    input.addEventListener('input', (e) => {
      this._value = e.target.value;

      const event = new CustomEvent('value-changed', {
        bubbles: true,
        composed: true,
        detail: {
          value: this._value,
          event: 'input'
        }
      });

      this.dispatchEvent(event);
    });
typescript    // change イベント(フォーカス離脱時)
    input.addEventListener('change', (e) => {
      const event = new CustomEvent('value-committed', {
        bubbles: true,
        composed: true,
        detail: {
          value: this._value,
          event: 'change'
        }
      });

      this.dispatchEvent(event);
    });
  }

  // 外部から値を取得・設定できるプロパティ
  get value() {
    return this._value;
  }

  set value(val) {
    this._value = val;
    const input = this.shadowRoot.querySelector('input');
    if (input) input.value = val;
  }
}

customElements.define('custom-input', CustomInput);

使用例は以下の通りです。

html<!-- HTML での使用例 -->
<custom-input id="nameInput" label="お名前"> </custom-input>

<script>
  const input = document.getElementById('nameInput');

  // リアルタイムで値の変更を監視
  input.addEventListener('value-changed', (event) => {
    console.log('入力中:', event.detail.value);
  });

  // 確定時の値を取得
  input.addEventListener('value-committed', (event) => {
    console.log('確定値:', event.detail.value);
  });
</script>

このパターンでは、2 種類のイベントを使い分けることで、入力中と確定時の処理を分離しています。両方のイベントで bubbles: true, composed: true を設定することで、フォーム全体で入力を管理できます。

実例 3: アコーディオンコンポーネント(内部イベント活用)

最後に、内部イベントを活用したアコーディオンコンポーネントを実装します。このコンポーネントは、開閉状態を内部で管理しつつ、外部には状態変更のみを通知します。

typescript// custom-accordion.ts
class CustomAccordion extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this._isOpen = false;
  }

  connectedCallback() {
    this.render();
    this.setupEventListeners();
  }
typescript  render() {
    this.shadowRoot.innerHTML = `
      <style>
        .accordion {
          border: 1px solid #ddd;
          border-radius: 4px;
        }
        .header {
          padding: 16px;
          background: #f5f5f5;
          cursor: pointer;
          user-select: none;
          display: flex;
          justify-content: space-between;
          align-items: center;
        }
        .header:hover {
          background: #e0e0e0;
        }
        .icon {
          transition: transform 0.3s;
        }
        .icon.open {
          transform: rotate(180deg);
        }
        .content {
          max-height: 0;
          overflow: hidden;
          transition: max-height 0.3s ease;
        }
        .content.open {
          max-height: 500px;
          padding: 16px;
        }
      </style>
      <div class="accordion">
        <div class="header">
          <slot name="title">タイトル</slot>
          <span class="icon">▼</span>
        </div>
        <div class="content">
          <slot name="content">コンテンツ</slot>
        </div>
      </div>
    `;
  }
typescript  setupEventListeners() {
    const header = this.shadowRoot.querySelector('.header');
    const content = this.shadowRoot.querySelector('.content');
    const icon = this.shadowRoot.querySelector('.icon');

    header.addEventListener('click', () => {
      // 内部状態変更イベント(Shadow 内のみ)
      const internalEvent = new CustomEvent('accordion-toggling', {
        bubbles: true,
        composed: false,  // 内部イベント
        detail: {
          currentState: this._isOpen,
          nextState: !this._isOpen
        }
      });
      this.shadowRoot.dispatchEvent(internalEvent);

      // 状態を更新
      this._isOpen = !this._isOpen;
      content.classList.toggle('open');
      icon.classList.toggle('open');
typescript      // 外部通知イベント(状態変更完了)
      const externalEvent = new CustomEvent('accordion-toggled', {
        bubbles: true,
        composed: true,  // 外部に通知
        detail: {
          isOpen: this._isOpen,
          timestamp: Date.now()
        }
      });
      this.dispatchEvent(externalEvent);
    });
  }

  get isOpen() {
    return this._isOpen;
  }
}

customElements.define('custom-accordion', CustomAccordion);

使用例は以下の通りです。

html<!-- HTML での使用例 -->
<custom-accordion id="myAccordion">
  <span slot="title">詳細情報</span>
  <div slot="content">
    <p>ここにアコーディオンの内容が表示されます。</p>
    <p>複数の段落を含めることができます。</p>
  </div>
</custom-accordion>

<script>
  const accordion = document.getElementById('myAccordion');

  // 外部イベントのみをキャッチ
  accordion.addEventListener(
    'accordion-toggled',
    (event) => {
      console.log(
        'アコーディオン状態:',
        event.detail.isOpen ? '開' : '閉'
      );

      // アナリティクス送信などの処理
      if (event.detail.isOpen) {
        sendAnalytics('accordion_opened');
      }
    }
  );
</script>

このコンポーネントの特徴は、内部イベント(accordion-toggling)と外部イベント(accordion-toggled)を使い分けている点です。内部イベントは Shadow DOM 内でのみ伝播し、デバッグや内部コンポーネント間の連携に使えます。

次の図は、このイベントフローを示しています。

mermaidflowchart TB
  User["ユーザークリック"]
  Header["header 要素"]

  subgraph Shadow["Shadow DOM 内"]
    Internal["内部イベント<br/>accordion-toggling<br/>(composed: false)"]
    State["状態更新<br/>UI 変更"]
  end

  External["外部イベント<br/>accordion-toggled<br/>(composed: true)"]
  Parent["親要素・document"]

  User --> Header
  Header --> Internal
  Internal --> State
  State --> External
  External --> Parent

  style Shadow fill:#ffffcc
  style Internal fill:#ffcccc
  style External fill:#ccffcc

この設計により、カプセル化を維持しながら、必要な情報のみを外部に公開できます。

TypeScript での型安全な実装

実務では TypeScript を使って型安全なイベント設計を行うことが推奨されます。以下は、カスタムイベントの型定義例です。

typescript// event-types.ts(型定義)
interface CustomClickDetail {
  timestamp: number;
  buttonText: string;
  originalEvent: MouseEvent;
}

interface ValueChangedDetail {
  value: string;
  event: 'input' | 'change';
}

interface AccordionToggledDetail {
  isOpen: boolean;
  timestamp: number;
}
typescript// 型安全なイベント作成ヘルパー
function createTypedEvent<T>(
  eventName: string,
  detail: T,
  options: Partial<CustomEventInit<T>> = {}
): CustomEvent<T> {
  return new CustomEvent<T>(eventName, {
    bubbles: true,
    composed: true,
    ...options,
    detail,
  });
}
typescript// 使用例
class TypedButton extends HTMLElement {
  emitClick(e: MouseEvent) {
    const event = createTypedEvent<CustomClickDetail>(
      'custom-click',
      {
        timestamp: Date.now(),
        buttonText: this.textContent || '',
        originalEvent: e,
      }
    );

    this.dispatchEvent(event);
  }
}
typescript// イベントリスナー側(型推論が効く)
button.addEventListener(
  'custom-click',
  (event: CustomEvent<CustomClickDetail>) => {
    // detail の型が推論される
    console.log(event.detail.timestamp); // number
    console.log(event.detail.buttonText); // string
  }
);

この型定義により、IDE の補完機能が使えるようになり、実装ミスを防げます。

まとめ

Web Components のイベント設計では、CustomEventcomposedbubbles の 3 つのキーワードを正しく理解することが重要です。それぞれの役割を改めてまとめます。

CustomEvent の基本

  • カスタムイベントを作成するための標準 API
  • detail プロパティで任意のデータを渡せる
  • bubblescomposedcancelable などのオプションで動作を制御

bubbles の使い分け

  • true:DOM ツリーを上方向に伝播させたい場合(ユーザーインタラクション、イベント委譲)
  • false:特定の要素でのみキャッチしたい場合(内部処理、パフォーマンス最適化)

composed の使い分け

  • true:Shadow DOM 境界を越えて外部に通知したい場合(グローバルイベント)
  • false:Shadow DOM 内でカプセル化を維持したい場合(内部イベント)

4 つの組み合わせパターン

パターンbubblescomposed用途
内部限定falsefalseデバッグ、内部ログ
Shadow 内バブリングtruefalse内部状態同期
境界越え単発falsetrue初期化通知
グローバル伝播truetrueユーザーインタラクション

これらの知識を活用することで、カプセル化を維持しながら、必要な情報を適切に外部に公開できるようになります。実装時は早見表を参考にしながら、目的に応じた設定を選択してください。

TypeScript を使った型安全な実装により、さらに保守性の高いコードを書けます。Web Components のイベント設計をマスターして、再利用可能なコンポーネントを作成しましょう。

関連リンク