ChatWork Creator's Note

ビジネスチャット「チャットワーク」のエンジニアとデザイナーのブログです。

React.Componentで外部要素のevent bind,unbindを正しく行う

正しくevent unbindできてますか?@kyo_agoです。

React.Componentの紹介を行っている記事でComponentの外にある要素のevent bind,unbindを正しく行えていない例がいくつかあったので記事にしてみました。

ここではwindow.onresizeに合わせてstateを変えるReact.Componentを例に紹介したいと思います。

正しくevent bind,unbindを行う

先に書いてしまいますが、eventのbind,unbindは以下のように行います。

class HogeComponent extends React.Component {
    constructor() {
        super();
        this.state = {height: 0};
    }
    componentDidMount() {
        // 先にbindしてから使う
        this.eventHandler = this.onResize.bind(this);
        window.addEventListener('resize', this.eventHandler);
    }
    componentWillUnmount() {
        window.removeEventListener('resize', this.eventHandler);
    }
    onResize() {
        this.setState({height: window.outerHeight});
    }
    render() {
        return <span>{this.state.height}</span>
    }
}

よくある間違い

わりとよく見る間違いは以下のものです。

class HogeComponent extends React.Component {
    constructor() {
        super();
        this.state = {height: 0};
    }
    componentDidMount() {
        // .bind(this)で新しいfunctionが生成される
        window.addEventListener('resize', this.onResize.bind(this));
    }
    componentWillUnmount() {
        // ここでも.bind(this)で新しいfunctionが生成されるので既存のfunctionは解除されない
        window.removeEventListener('resize', this.onResize.bind(this));
    }
    onResize() {
        // componentWillUnmount 後も発火し続ける(componentWillUnmount 後は this.setState はエラーになる)
        this.setState({height: window.outerHeight});
    }
    render() {
        return <span>{this.state.height}</span>
    }
}

この例ではonResize内でthis.setStateを使用しているためエラーが発生しますが、componentWillUnmount内のremoveEventListenerではエラーは発生しません。

処理によってはunbindに失敗したことに気づかずcomponentのcomponentDidMount -> componentWillUnmount毎にイベントがbindされてメモリリークの原因となることもあります。

また、以下のようなものもありました。

class HogeComponent extends React.Component {
    constructor() {
        super();
        this.state = {height: 0};
    }
    componentDidMount() {
        window.addEventListener('resize', this.onResize);
    }
    componentWillUnmount() {
        window.removeEventListener('resize', this.onResize);
    }
    onResize() {
        // thisがwindowになるためsetStateできない
        this.setState({height: window.outerHeight});
    }
    render() {
        return <span>{this.state.height}</span>
    }
}

この例ではeventのbind,unbindは問題なく行われていますが、eventHandlerではthisがwindowになるためinstanceの操作が行えなくなります。
(instanceの操作を行わない場合、この方法でも問題ありません)

他の実装例

.bind(this)で変数に保存する以外に、以下のような方法でも正しくevent bind,unbindすることができます。

class HogeComponent extends React.Component {
    constructor() {
        super();
        this.state = {height: 0};
    }
    componentDidMount() {
        // addEventListenerの第2引数がhandleEvent propertyを持つ場合それをbind(this)して呼び出す
        window.addEventListener('resize', this);
    }
    componentWillUnmount() {
        // そのままunbindできる
        window.removeEventListener('resize', this);
    }
    // handleEvent内はthisがwindowではなく、HogeComponentのinstanceのままになる
    handleEvent(event) {
        // bindするeventが一つの場合、分岐せずthis.onResize();を呼び出してもいい
        switch(event.type) {
            case 'resize':
                this.onResize();
                break;
            // addEventListenerを複数行っている場合、event.typeで分岐する
        }
    }
    onResize() {
        this.setState({height: window.outerHeight});
    }
    render() {
        return <span>{this.state.height}</span>
    }
}

ここで使用しているhandleEventはDOM APIで標準的に提供されている機能です。

注意

これはReact.Component以外でも起こりえる問題ではありますが、React.Componentのサンプルではいくつか間違った実装を見たため共有します。
(おそらくReact.createClassにauto-binding機能が存在したため、その頃の記述をベースにしてるのではないかと思っています)