ReactでアンマウントしたコンポーネントにsetStateしてしまう問題の解決

前回の記事でReactを0.14にバージョンアップしました。

Reactを0.14にバージョンアップしてみた | MAMANのITブログ

上記が問題かは分かりませんが、別の問題が発生したのでその対応をしました。

バージョンの詳細

利用している主な関係パッケージのバージョンです。

パッケージ名 バージョン
react 0.14.3
react-bootstrap 0.28.7
react-router 1.0.1
events 1.0.2
history 1.17.0

エラーの概要

ログ

正確にはWarningですが以下の様なエラーログが表示されました。

Warning: setState(...): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op. Please check the code for the undefined component.

「マウントされていないコンポーネントに対してsetState()されているぞ」という警告です。

画面

前提として以下の画面が存在します。

  • ナビゲーションバー(react-router使用)のルート画面
  • ナビゲーションバーの項目をクリックすると遷移する以下の2画面
    • ajaxで検索した結果をグラフで表示する画面 (検索表示画面)
    • 固定された項目をグラフで表示する画面 (固定画面)

再現手順

  1. 検索表示画面で検索し、結果を表示させる
  2. ナビゲーションバーから固定画面に移動する
  3. ナビゲーションバーから検索表示画面に移動する
  4. 検索表示画面で検索し、結果を表示させる

4の手順を行った際、コンソールにエラーログが表示されます。
その後、2と3を繰り返す度に4で表示されるエラーログが段々と増えていきます。

その他

EventEmitterを使ってViewからStoreの変化を監視しています。

原因

コンポーネントがアンマウントされた時に、StoreのEventListenerが破棄されていなかったことが原因でした。
その為、再び同じ画面に移動したとき、今まで追加したEventListenerに加えて新しいListenerが追加されていたのです。

以下は具体的なコードです。
想像に任せる部分も多いですが、componentWillUnmountが上手く動いていませんでした。

export default class extends React.Component {

    constructor(props) {
        super(props);
        this.state = {
            repositories: RepositoryStore.getItems(),
            isLoading: RepositoryStore.isLoading()
        };
    }

    componentDidMount() {
        RepositoryStore.addChangeListener(this._onChange.bind(this));
    }

    componentWillUnmount() {
        RepositoryStore.removeChangeListener(this._onChange.bind(this));
    }

    _onChange() {
        //...省略
    }

    render() {
        //...省略
    }

同じthis._onChange.bind(this)を引数に入れていても、RepositoryStore.addChangeListenerRepositoryStore.removeChangeListenerで渡した参照先は異なるようです。

これはEventEmitterの問題ではなく、bind(this)の仕様みたいです。

console.log(this._onChange === this._onChange) // -> true
console.log(this._onChange.bind(this) === this._onChange.bind(this)) // -> false

解決方法

初期時にthis._onChange.bind(this)の参照先をフィールドに確保しておくようにしました。

export default class extends React.Component {

    constructor(props) {
        super(props);
        this.state = {
            repositories: RepositoryStore.getItems(),
            isLoading: RepositoryStore.isLoading()
        };
        // 参照先を確保
        this._onChange = this._onChange.bind(this);
    }

    componentDidMount() {
        RepositoryStore.addChangeListener(this._onChange);
    }

    componentWillUnmount() {
        RepositoryStore.removeChangeListener(this._onChange);
    }

    _onChange() {
        //...省略
    }

    render() {
        //...省略
    }

下記で色々議論されていましたが、他の方法では解決できていませんでした。

reactjs – Stores’ change listeners not getting removed on componentWillUnmount? – Stack Overflow

bind(this)についても説明されていますね。

If your code, you are passing this._onChange.bind(this) to addListener. bind returns a new function that is bound to this. You are then discarding the reference to that bound function. Then you try to remove another new function created by a bind call, and it’s a no op, since that was never added.

今回の解決方法も2015/12/30現在でレスはついていません。
ただ、Facebookのブログで記載があったので的外れではないと判断しました。

React v0.13.0 Beta 1 | React

総括

アンマウントしたコンポーネントにsetStateするとWarningが発生する問題を解決しました。

react-bootstrapのModal Dialog対応をしている最中に発生した事象だったので、原因がReact ES6化によるものと気づくまでかなり時間がかかりました。

日本語でググってもすぐヒットしなかったので、何かのお役に立てれば幸いです。

ReactでアンマウントしたコンポーネントにsetStateしてしまう問題の解決” に対して 2 件のコメントがあります

  1. rooooomania より:

    この記事のおかげで、相当時間を節約したように思います。
    github のコードサンプルなどで、 constructor に this.myMethod.bind(this) をあらかじめセットしているケースをよく見かけますが、単純な使い勝手の他に、このような大きな目的があったのだと気付けました。どうもありがとうございます!

    1. tadashi-aikawa より:

      rooooomaniaさん

      コメントありがとうございます。

      仰る通り、よく紹介されているのは`onClick`などのイベントハンドラ用メソッドに`this`をbindするケースですが、`onChange`にも効用があるとは思いませんでした。
      Reactは変化が目まぐるしいので、Warning対応も大変ですね…

コメントを残す