27
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

JustSystemsAdvent Calendar 2017

Day 14

React-Reduxのイベントハンドラ定義に関するTips/Pitfalls

Last updated at Posted at 2017-12-14

導入

この記事でイベントハンドラと言っているのはJSXのタグに渡すコールバック関数のことです。

return <button onClick={(event) => { console.log('clicked!!') }}>ボタン</button>
//               ↑これ

イベントハンドラに関する情報で、本格的な実装を始める前に知っておいた方が良さそうな点を中心に紹介します。

ReactのSyntheticEvent(ブラウザネイティブイベントのラッパー)

Reactのコンポーネントで発生するイベントは、ブラウザネイティブのイベントをラップしたものになっていて、ブラウザ間差異がある程度吸収されています。参考 → 公式Doc

APIの違いなどはほとんど無いです。気をつけるのは以下の2点くらい。

  • 関数内でreturn false;してもイベントの伝搬が止まらない。
    • 止めたいときは明示的にevent.stopPropagation()などを呼びます。
  • 非同期的にEventオブジェクトを参照したい場合は、event.persist()する。
    • パフォーマンスのためにオブジェクトを使い回しているらしいので、何もしないと変な状態になります。

イベント周りでブラウザ間の挙動の違いを見つけたら、まずはリリースノートを見に行くといいです。アップデートで改善されていたり、特定バージョンでの一時的なバグだったりすることがあります。未知のバグであればIssueに報告しておくと解決されるかもしれないです。
ラップされているとはいっても、ブラウザ間差異が完全に無くなっている、というわけではないです。印象としては"できるとこだけやる"くらいです。

(アップデートで解決しない || アップデートできるタイミングじゃない) というときはあきらめて差異を吸収する実装をしましょう。:joy:

Componentのpropsバケツリレーで段階的に情報を挿入

各コンポーネントが、描画に必要な情報のみを受け取るように実装していくと、コンポーネントの実装がシンプルになります。

Actionを発行するためには情報が必要だが、その情報が描画には必要ないものなので、コンポーネントに渡したくない、というときがあります。

それがComponentツリーの途中で発生すると、ContainerComponentでイベントハンドラを定義したあと、Componentツリーを下っていく過程で段階的に情報を挿入する、という発想になります。

実装方針が2つあります。

部分適用を使うパターン

class HogeNameBox extends React.Purecomponent {
    render() {
        return (
            <input
                type="text"
                className="hogeNameBox"
                onChangeName={this.props.onChangeName}
                value={this.props.name}
            />
        );
    }
}

class Hoge extends React.Purecomponent {
    render() {
        const { hoge, onChangeName } = this.props;

        return (
            <div className="hoge">
                <HogeNameBox
                    name={hoge.name}
                    onChangeName={(event) => { onChangeName(event, hoge.id); }}  // 部分適用
                />
            </div>
        );
    }
}

class HogeList extends React.PureComponent {
    renderItem(id) {
        return (
            <Hoge
                hoge={this.props.byId[id]}
                onChangeName={this.props.onChangeName}
            />
        );

    }

    render() {
        return (
            <div className="hogeList">
                {this.props.idList.map((id) => { return renderItem(id); })}
            </div>
        );
    }
}

function mapStateToProps(state) {
    return {
        idList: [ '1', '2', '3' ],
        byId: {
            '1': { id: '1', name: 'hoge1' },
            '2': { id: '2', name: 'hoge2' },
            '3': { id: '3', name: 'hoge3' },
        },
    };
}

function mapDispatchToProps(dispatch) {
    return {
        onChangeName: (event, id) => { // 普通の関数として定義
            dispatch(onChangeName(id, event.value));
        },
    };
}

const HogeListContainer = connect(mapStateToProps, mapDispatchToProps)(HogeList);

クロージャを使うパターン

class HogeNameBox extends React.Purecomponent {
    // 略
}

class Hoge extends React.Purecomponent {
    render() {
        const { hoge, onChangeName } = this.props;

        return (
            <div className="hoge">
                <HogeNameBox
                    name={hoge.name}
                    onChangeName={onChangeName(hoge.id)} // ここがシンプルになる
                />
            </div>
        );
    }
}

class HogeList extends React.PureComponent {
    // 略
}

function mapStateToProps(state) {
    // 略
}

function mapDispatchToProps(dispatch) {
    return {
        onChangeName: (id) => (event) => { // クロージャ
            dispatch(onChangeName(id, event.value));
           },
    };
}

const HogeListContainer = connect(mapStateToProps, mapDispatchToProps)(HogeList);

比較

  • 部分適用
    • 定義側がシンプル、コンポーネント側が煩雑
    • 情報を挿入する順番が変わっても変更箇所が少ない
    • 定義側で引数をどの順番で定義するか迷う
  • クロージャ
    • 定義側が煩雑、コンポーネント側がシンプル
    • 情報を挿入する順番が変わると変更箇所が多い
    • 定義側で命名が難しい

Componentのrender関数内で関数を生成しない(方が高速)

Componentのrender関数内で、JSXタグのon~属性を定義するところで、Arrow Functionを使うパターンは、広く使われています。(airbnbのlintも叱ってくれないのが増殖に拍車をかける・・)

class Hoge extends React.Purecomponent {
    render() {
        const { hoge, onChangeName } = this.props;

        return (
            <div className="hoge">
                <HogeNameBox
                    name={hoge.name}
                    onChangeName={(event) => { onChangeName(event, hoge.id); }} // 部分適用
                />
            </div>
        );
    }
}

このパターン、実は性能を悪化させるリスクがあります。 詳しくは → こちら

PureComponentなどを使っていると、コンポーネントの再描画時はpropsを前回の状態とshallow equalで比較して、同じだった場合は描画が省略されます。

render関数内でArrow Functionやクロージャを使って関数を生成して、コンポーネントに渡してしまうと、毎回違うインスタンスが生成されます。つまり、中身が全く同じでも、再描画チェックでの比較が常にfalseとなり、無駄に再描画されてしまいます。

以下のように、クラスのプロパティに関数を定義して、その中でイベントハンドラを呼び出すと改善します。再描画チェックで比較される関数を、クラスのプロパティにすることで、常にtrueになるようになります。(babel-plugin-transform-class-propertiesが必要)

class Hoge extends React.Purecomponent {
    handleOnClick = (event) => {
        const { hoge, onChangeName } = this.props;
        onChangeName(event. hoge.id); // 部分適用
    }
    render() {
        const { hoge, onChangeName } = this.props;

        return (
            <div className="hoge">
                <HogeNameBox
                    name={hoge.name}
                    onChangeName={this.handleOnClick}
                />
            </div>
        );
    }
}

個人的には、プロジェクトにbabel-pluginを追加するのは抵抗があるのですが、transform-class-propertiesに関しては、Flowを導入するとセットで入れることになるのでどうでもよくなりました。:grin:

イベントハンドラー内で発生したエラーはError Boundariesでキャッチできない

React v16で追加されたError Boundariesですが、イベントハンドラー内で発生したエラーはError Boundariesに行きません。

参考 → 公式Doc

対策①:イベントハンドラ内ではアクションをdispatchするだけにする。

例外が発生しうる処理はかかないのが原則。

Actionがdispatchされた後の処理で発生するエラーは、それぞれcatchできる場所があるので、そっちで処理します。

  • Reducer内やMiddleware内の同期処理 → redux-catch
  • Middleware内の非同期処理 → promiseの末端でcatch
  • Componentの描画ライフサイクルまで行った後のエラー → Error Boundaries

ActionCreatorは呼ぶことになるので、そこで色々やる実装方針だと困るかもしれません。とはいってもイベント処理関数側でエラーをcatchしないといけないケースはほとんど無いはず。

例えばredux-thunkは、ActionCreatorが関数を返すようになるが、その関数の呼び出しはMiddleware内で行われます。非同期処理のエラーはPromiseの末端で、それ以外はredux-catchで処理することになるはず。(thunkは使ってないので間違ってるかも)

対策②:コンポーネントのイベントハンドラ定義時にtry catchする

公式Docのようにコンポーネント側のハンドラ定義内で、try catchする方針です。

一応、以下の様にすれば、イベントハンドラのエラーを最寄りのError Boundaryに処理させることはできます。

  • catchしたときに、this.setStateを使ってエラーオブジェクトを保存。stateを変更するので勝手にrenderのライフサイクルをトリガーする。
  • render関数内でstateに保存したエラーオブジェクトをthrowする(他のライフサイクル関数内でのthrowでもOK)

codepan -> https://codepen.io/iray-tno/pen/qpEMgN?editors=0010

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  componentDidCatch(error, info) {
    this.setState({ hasError: true });
  }
  render() {
    return this.state.hasError ? <h1>ERROR!</h1> : this.props.children;
  }
}

class Hoge extends React.Component {
  constructor() {
    super();
    this.state = { error: null };
  }
  handleOnClick = () => {
    try {
      throw new Error('hoge');
    } catch (error) {
      // 発生したErrorをStateにセットする
      // コンポーネントのライフサイクル関数が勝手に呼ばれる
      this.setState({ error });
    }
  }
  render() {
    if (this.state.error) {
      // エラーが発生していたらrender関数などでthrowする。
      throw this.state.error;
    }
    return <button onClick={this.handleOnClick}>hoge</button>;
  }
}

ReactDOM.render(<ErrorBoundary><Hoge /></ErrorBoundary>, document.getElementById('app'));

対策③:connect時のイベントハンドラ定義で、try catchする

エラーが起きたらエラー処理用のアクションをdispatchする方針です。汎用的な仕組みにして、connect関数に定義するイベントハンドラすべてに対応することも不可能ではなさそう。(そこまでやるか?)

現実解は、対策①+redux-saga(などのエラー処理しやすいライブラリ)をベースに、handlerで色々やっているような特殊なコンポーネントのみ対策②をやる、くらいでしょうか。

eslint-plugin-jsx-a11y(アクセシビリティ関連)

アクセシビリティ関連のベストプラクティスは見落としがちです。eslint-plugin-jsx-a11yを使ったルールを早めに通すようにしましょう。

イベントハンドラ関連のルールは、後から対応しようとすると特に苦労しそうな印象があります。

インタラクティブな要素は、実装初期の段階で、divっぽくスタイルをリセットしたbutton、spanっぽくしたbutton・・・のように、薄くラップした汎用コンポーネントを用意しておくといいかもしれません。

汎用コンポーネント側で、css-modulesやCSS-in-JSによるスタイルのリセットに加え、イベントハンドラのアクセシビリティ対応を一括でできるようにしておくと後でルールやベストプラクティスが変わっても大丈夫です。

27
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?