LWCでCustomEventを多用しすぎて後悔した話

C. 後から「やらなければよかった」と思った設計

LWCに慣れてきた頃、とにかくコンポーネント間の連携はCustomEventを使えば何でもできると思い込んでいた時期があった。特に親から子へのデータ受け渡しでさえ、@apiプロパティを使うのではなく、親が子に対してイベントを発火させ、子がそれを購読する、という一見すると柔軟な設計を試みた。

当時の私は、JavaScriptのイベント駆動モデルに慣れていたこともあり、「イベントこそが疎結合の極みだ!」と信じて疑わなかった。親コンポーネントが子の状態を直接操作するのを避け、子コンポーネント自身がイベントに反応して振る舞いを変更する方が、再利用性も高まると考えていたのだ。

その時の判断ミス:親から子へのデータ伝達をCustomEventで行う

具体的には、親コンポーネントで取得したリストデータを子コンポーネントに渡して表示させたい、という要件があった。普通なら、子の@apiプロパティにリストを渡すのが定石だ。

// 親コンポーネント (親が子にデータを渡す通常の方法)
<template>
    <c-child-component items={parentData}></c-child-component>
</template>

// 子コンポーネント
import { api, LightningElement } from 'lwc';

export default class ChildComponent extends LightningElement {
    @api items; // 親から直接データを受け取る
}

ところが、当時の私は以下のような実装を選んだ。

// 親コンポーネント (当時、私が選んだ設計)
<template>
    <c-child-component onready={handleChildReady}></c-child-component>
</template>

import { LightningElement } from 'lwc';
import getSomeData from '@salesforce/apex/SomeController.getSomeData';

export default class ParentComponent extends LightningElement {
    parentData = [];

    connectedCallback() {
        this.fetchData();
    }

    async fetchData() {
        this.parentData = await getSomeData();
        // ここではまだ子にデータを渡さない
    }

    handleChildReady() {
        // 子が "ready" イベントを発火した時に、親から子にデータを送るCustomEventを発火
        const sendDataEvent = new CustomEvent('parentdatareceived', {
            detail: { data: this.parentData }
        });
        this.template.querySelector('c-child-component').dispatchEvent(sendDataEvent);
        // ↑今ならこれは絶対しない。querySelectorで子コンポーネントのDOM要素を取得して直接dispatchEvent?冗談だろ。
        // もしくは、親コンポーネント自身のDOM要素に対してdispatchEventして、子がそれをlistenする。
        // いずれにせよ、複雑すぎる。
    }
}

// 子コンポーネント (当時、私が選んだ設計)
import { LightningElement } from 'lwc';

export default class ChildComponent extends LightningElement {
    childItems = [];

    connectedCallback() {
        // 子自身のDOMにイベントリスナーを追加して、親からのデータイベントを待つ
        this.addEventListener('parentdatareceived', this.handleParentDataReceived);
        // 子が準備できたことを親に伝えるイベント
        this.dispatchEvent(new CustomEvent('ready'));
    }

    handleParentDataReceived(event) {
        this.childItems = event.detail.data;
    }
}

この設計の何がまずかったか。まず、親コンポーネントが子コンポーネントに対して直接dispatchEventしている点だ。これはコンポーネント間の結合度を高める。なぜなら、親が子の特定のイベント名とdetailの構造を知っている必要があるからだ。しかも、this.template.querySelector('c-child-component').dispatchEvent(sendDataEvent);というコードは、親が子のDOM構造に依存していることを意味する。子の要素がテンプレート内でどのように配置されているかを知っている必要がある。これはLWCのコンポーネント指向原則に反する。

さらに、CustomEventでデータを渡すことは、TypeScriptを使ったとしても型安全性が低い。event.detailの構造は実行時まで分からないため、開発中にタイプミスがあっても気づきにくい。@apiプロパティであれば、プロパティの型が明確になり、IDEの恩恵も受けやすい。

このプロジェクトでは、子コンポーネントが複数存在したり、孫コンポーネントにまでデータを渡す必要が出てきたりした時に、イベントが雪崩のように発生し、何がどのイベントでトリガーされているのか、どこでデータが変更されているのかを追うのが非常に困難になった。デバッグはもはやイベントのログを睨みつける作業と化し、そのたびに「なぜこんな設計にしたんだろう」と後悔した。

今なら別の選択をする

今なら、この要件に対しては素直に@apiプロパティを使う。親から子へのデータ伝達は、最もシンプルで堅牢な方法だ。

もし、コンポーネント階層が深くなったり、非直接的な兄弟コンポーネント間で連携が必要になったりするなら、Lightning Message Service (LMS) や、シンプルなPub-Subパターン、あるいはServiceコンポーネントの導入を検討する。少なくとも、querySelectorで子コンポーネントを特定し、直接イベントを発火させるような愚行は二度としない。

あの時の「疎結合への過度な信仰」と「イベント駆動モデルへの盲信」が、結果的にメンテナンス困難なコードを生み出したことを反省している。シンプルさが一番だということを、この経験を通じて痛感した。


これは当時の自分向けのメモだ。

コメント