対象読者
- 再利用可能なHTMLやCSSを整備したいJavaScriptエンジニア
- ReactからWeb Componentsを利用してみたいReactエンジニア
前提環境
筆者の検証環境は以下の通りです。
- macOS Sonoma 14.2.1
- Google Chrome 121.0.6167.184
- Node.js v21.6.2
- npm v10.2.4
- react 18.2.0
- react-dom 18.2.0
- lit 3.1.2
- @lit/react 1.0.3
Web ComponentsとReactをより密接に連携させる
前回は、Web ComponentsとReactを連携させるために、@lit/react を使って、単純にデータを外から中へと流し込む方法を解説しました。
今回はもう少し密接な連携を目指します。Reactコンポーネントとしての自然な振る舞いを求めるのであれば、コンポーネント内部で発生したイベントを、React側で関数コールバックとして受け取ることができるようにしたいですよね。また、他のコンポーネントの外側に被せることで意味を持つタイプのコンポーネントであれば、Reactの children と同じように、子要素を扱えると嬉しいです。@lit/react を使って、これらの挙動を実現する方法を解説していきます。
動作確認の環境については、前回のものをそのまま踏襲するので、セットアップ方法については前回の記事をご覧ください。
Web Components内で発火したイベントをReactで受け取る
イベントのコールバックをReactで受け取る方法を見てみましょう。といっても、@lit/react の力を持ってしても、Reactから受け取った関数コールバックをカスタム要素の内部まで届けることはできません。カスタム要素の内部でイベントを発火させて、それをReactで受け取るという方法をとります。前回のリスト4を少し改造する形で、イベントを発火させる実装を追加したのがリスト1です。
import {LitElement, html} from 'lit';
export class MyEventedCounterElement extends LitElement {
static properties = {
count: { type: Number }
}
constructor() {
super();
this.count = 0;
}
render() {
return html`
<p>カウント: ${this.count}</p>
<button @click="${() => this.increment()}">+1</button>
`;
}
increment() {
this.count += 1;
// (1)
this.dispatchEvent(new CustomEvent(
'changed',
{
bubbles: true, // (3)
composed: true, // (4)
detail: { count: this.count } // (2)
}
));
}
}
customElements.define('my-evented-counter-element', MyEventedCounterElement);
(1)の dispatchEvent で、changed という名前のCustomEventを発火させています。外部に現在のカウントを伝えたいので、(2)のようにカウント数を detail に入れているのは理解しやすいと思います。(3)のbubbles と(4)の composed は、イベントの伝播に関する設定です。bubbles をtrueにすると、イベントがDOMツリーを遡って祖先に伝播します。composed をtrueにすると、シャドウDOMの境界を越えてイベントが伝播します。つまり、両方をtrueにしておくと、シャドウDOMの内側から発火したイベントが、シャドウDOMの外側にあるDOMツリーを遡って、Reactの管理下まで伝播するようになります。
では、リスト1のカスタム要素から発火したイベントを、Reactで受け取ってみましょう。リスト2のように、@lit/react を使ってReactコンポーネントを作成します。
import React, { useState } from 'react';
import { createComponent } from '@lit/react';
import { MyEventedCounterElement } from './my-evented-counter-element';
const MyEventedCounterElementComponent = createComponent({
tagName: 'my-evented-counter-element',
elementClass: MyEventedCounterElement,
react: React,
events: {
onChanged: 'changed', // (1)
}
})
export const EventHandler = () => {
const [count, setCount] = useState(0);
const isOdd = Math.abs(count % 2) === 1;
return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<MyEventedCounterElementComponent
onChanged={(ev) => { // (2)
setCount(ev.detail.count); // (3)
}}
/>
<p>
{count}は{isOdd ? '奇数' : '偶数'}です。{/* (4) */}
</p>
</div>
)
}
実際に動かしてみましょう。ブラウザで http://localhost:5173/ を開くと、図1の通り表示されます。
カスタム要素内のカウントをReact側で受け取り、奇数か偶数かの判定と表示をReact側で行っています。
リスト2で特に注目したいのは、(1)の events です。ここには、カスタム要素から発火されるイベント名と、React側で受け取る際の名前を指定します。(1)のように設定すると、カスタム要素内で changed という名前のイベントが発火されたときに、React側で onChanged という名前のコールバックが呼ばれるようになります。実際に呼ばれると、(2)の引数には dispatchEvent() で発火した CustomEvent が渡されるので、(3)のように detail からカウント数を取り出すことができています。
明示的に dispatchEvent() を呼び出したり、イベント名を指定したりと、少し手間がかかりますが、カスタム要素内で発火したイベントをReact側で受け取ることができました。
