React HOC集中探求(2)

1部では、HOF(Higher Order Function)とHOC(Higher Order Component)の概念と、どのような状況で使用できるかについて調べました。しかし、HOCを実際のプロジェクトにどのように活用できるか、イメージしにくかったと思います。今回は実際に簡単なHOCを作って、その過程で発生し得る問題と解決方法を紹介したいと思います。

基本サンプル:windowのスクロールを追跡する

簡単な例から始めてみよう。まずウィンドウのスクロール位置が変更されるたび、その位置を画面に出力するコンポーネントを作成してみよう。そのためには、コンポーネントがmountされるときと、unmountされるときに、windowscrollのイベントハンドラをそれぞれ登録・解除する過程が必要です。

class WindowScrollTracker extends React.Component {
  state = {
    x: 0,
    y: 0
  }

  scrollHandler = () => {
    this.setState({
      x: window.pageXOffset,
      y: window.pageYOffset
    });
  };

  componentDidMount() {
    window.addEventListener('scroll', this.scrollHandler);
  }

  componentWillUnmount() {
    window.addEventListener('scroll', this.scrollHandler);
  }

  render() {
    return (
      <div>
        X: {this.state.x},
        Y: {this.state.y}
      </div>
    );
  }
}

上記のように実装できます。しかし、もしWindowsのスクロール位置が変更されるたびに、反応しなければならないコンポーネントが複数あった場合はどうでしょうか?この場合は、同じタイプのロジックが各コンポーネントに重複して入るでしょう。これらのロジックを横断的関心事(Cross-Cutting Concerns)と呼び、HOCを活用するとコードの重複を効率的に除去できます。上記のコンポーネントのコードを活用してHOCを作成しよう。まずHOCの定義が何か思い出してみよう。

const compY = HOC(compX);

HOCはコンポーネントを引数として受け取って新しいコンポーネントを返す関数です。つまり最初にすべきことは、関数の作成です。

function withWindowScroll(WrappedComponent) {
  return class extends React.Component {
    // ...
  }
}

新たに返されるコンポーネントのクラスは、renderメソッドを除き、最初に作ったWindowScrollTrackerと同じものです。renderメソッドでは、withWindowScroll関数から引数で受け取ったWrappedComponentをレンダリングしながら、stateで管理しているxyの情報をpropsに渡します。
ここで注意する点は、xyの他にもWrappedComponentが使用するpropsがあるので、それもすべて渡すということです。

function withWindowScroll(WrappedComponent) {
  return class extends React.Component {
    // ... 残りのコードはWindowSizeTrackerと同じ
    render() {
      return <WrappedComponent {...this.props} x={this.state.x} y={this.state.y} />
    }
  }
}

withWindowScrollHOC関数を利用して、最初に作ったWindowSizeTrackerを再定義しよう。既存のロジックでrenderメソッドだけを残してすべて削除した後、HOCを呼び出して新しいコンポーネントを定義します。renderメソッドではthis.stateの代わりにwithWindowScrollを使用してダウンロードしたpropsを利用します。

function PositionTracker({x, y}) {
  return (
    <div>
      X: {x}, Y: {y}
    </div>
  )
}

const WindowScrollTracker = withWindowScroll(PositionTracker);

こうして作成されたHOCは、ウィンドウのスクロール位置を反映すべき、いかなるコンポーネントにも使用できます。例えば、スクロールが最上段に位置するときだけ「Top」という文字を画面に出力するコンポーネントがある場合、次のように簡単に実装できます。

function TopStatus({y}) {
  return (
    <div>
      {y === 0 && "It's on Top!!"}
    </div>
  )
}

const WindowScrollTopStatus = withWindowScroll(TopStatus);

HOCカスタマイズ1:パラメータを追加する

上記のサンプルをもう少し発展させよう。もし、withWindowScrollを使ったコンポーネントがスクロールイベントをthrottleさせて使用させたい場合、どうすればよいでしょうか?コンポーネントごとに必要なwait値が異なるケースがあるため、追加の引数が必要です。2番目の引数をオブジェクト形式で受け取り、必要なカスタム値が取得できるように既存のコードを修正しよう。

import {throttle} from 'lodash-es';

function withWindowScroll(WrappedComponent, {wait = 0} = {}) {
  return class extends React.Component {
    // 残りのコードは同じ

    // thisバインディングのためClass Field Declaration文法を使った
    // (現在State3状態なので、使用できない環境ではbind関数を用いるべき)
    // 追加コードを単純化するためにwait値が0の場合もthrottle関数を用いることにする
    scrollHandler = throttle(() => {
      this.setState({
        x: window.pageXOffset,
        y: window.pageYOffset
      });
    }, wait);

    render() {
      return <WrappedComponent {...this.props} x={this.state.x} y={this.state.y} />
    }
  }
}

次のように各コンポーネントが必要なthrottle値を超えて使用できるようになりました。

const WindowScrollTracker = withWindowScroll(PositionTracker, {wait: 30});
const WindowScrollTopStatus = withWindowScroll(TopStatus, {wait: 100});

HOCカスタマイズ2:props mapper

withWindowScrollの関数によって返却されるコンポーネントは、xyをpropsに注入しています。このとき、同じ名前のpropsを注入する別のHOCに入れ子にして使用した場合、どうなるでしょうか?例えばマウスの位置をxyという名前で注入するwithMousePositionというHOCを次のように使用したと仮定します。

const SinglePositionTracker = withMousePosition(withWindowScroll(PositionTracker));

この場合、withWindowScrollを介して注入されたxywithMousePositionで注入されるxyによって上書きされます。このように、入れ子にしたHOCのprops名が衝突することがHOCの欠点ですが、mapper関数を提供することで簡単に解決できます。

react-reduxライブラリのconnect関数で使用するmapStateToPropsを想像すると、簡単に理解できるでしょう。connect関数の役割の1つは、ストアに格納されたstateをコンポーネントに注入させますが、このときmapStateToPropsの関数を利用して、コンポーネントが使用したいstateだけを選択し、希望するpropsの名前も指定することができます。

const mapStateToProps = (state) => ({
  userName: state.user.name,
  userScore: state.user.score
});

const ConnectedComponent = connect(mapStateToProps)(MyComponent);

こうすると、MyComponentは全てのstateの代わりにuserNameuserScoreという名前のpropsだけ渡されます。同様にHOC関数を呼び出す際、注入したいpropsをオブジェクトとして返却するmapper関数に渡せるようにすると名前の衝突を解決できます。withWindowScrollwithMousePosition関数に適用すると、以下のように使用できます。

function PositionTracker({scrollX, scrollY, mouseX, mouseY}) {
  return (
    <div>
      ScrollX: {scrollX},
      ScrollY: {scrollY},
      mouseX: {mouseX},
      mouseY: {mouseY}
    </div>
  )
}

const windowScrollOptions = {
  wait: 30,
  mapProps: ({x, y}) => ({
    scrollX: x,
    scrollY: y
  })
}

const mousePositionOptions = {
  mapProps: ({x, y}) => ({
    mouseX: x,
    mouseY: y
  })
}

const EnhancedPositionTracker = withMousePotision(
  withWindowScroll(PositionTracker, windowScrollOptions),
  mousePositionOptions
);

withWindowScroll関数も少し修正して、mapProps関数をサポートできるようにしてみよう。

import {throttle, identity} from 'lodash-es';

function withWindowScroll(WrappedComponent, {wait = 0, mapProps = identity} = {}) {
  return class extends React.Component {

    render() {
      const {x, y} = this.state;
      const passingProps = mapProps({x, y});

      return <WrappedComponent {...this.props} {...passingProps} />
    }
  }
}

mapPropsを使用すると、演算から全く異なるデータをpropsに転送することもできます。たとえば上記で実装したTopStatusの場合、スクロールのY値が0か否か分かればよいですが、mapPropsを活用すると、次のように効率的に実装できます。

class TopStatus extends React.PureComponent {
  render() {
    return (
      <div>
        {this.props.isScrollOnTop && "It's on Top!!"}
      </div>
    )
  }
}

const ScrollTopStatus = withWindowScroll(TopStatus, {
  wait: 30,
  mapProps: ({y}) => ({
    isScrollOnTop: y === 0
  })
});

上記からTopStatusPureComponentであることが分かるでしょう。TopStatusはスクロールYの値が0か否か確認するだけですが、既存の実装ではスクロール値が変更されるたびに、常に新しいpropsを受け取るため、不要なレンダリングが継続して発生していました。変更されたコードではisScrollOnTop値が変更されるときだけpropsに渡されるので、PureComponentを使って不要なレンダリングを防止できます。

HOCを入れ子に – compose

上記のwithMousePositionwithWindowScrollを入れ子にして使用する場合のコードをもう一度見てみよう。

const EnhancedPositionTracker = withMousePotision(
  withWindowScroll(PositionTracker, windowScrollOptions),
  mousePositionOptions
);

関数の呼び出しと、それぞれの引数の値が重なり、一目で理解するのは大変です。もしここで、いくつかのHOCをさらに入れ子にして使用することになれば、コードはますます分かりにくくなります。

これらの問題を解決するために、ReactではHOCのコンベンションを提案しています。コンベンションはreact-reduxのconnect関数のようなものと見做せますが、下記のようにconnect関数はHOCではなくHOCを返却する関数です。

// 追加の引数が適用されたHOCを生成する
const enhance = connect(mapStateToProps, mapDispatchToProps);

// HOCは1つの引数(コンポーネント)だけをもらう
const EnhancedComponent = enhance(MyComponent);

つまり、HOC関数が常に1つの引数(コンポーネント)を受信できるようにして、別の関数を提供しています。この形式に合わせてwithWindowScrollのAPIを変更すると、次のようになります。

const windowScrollOptions = {
  wait: 30,
  mapProps: ({y}) => ({
    isScrollOnTop: y === 0
  })
};

const enhance = withWindowScroll(windowScrollOptions);

const EnhancedComponent  = enhance(MyComponent);

このように、すべてのHOCが同一引数のみ取得すると、Component => Componentと同じ形式になるので、lodashのflowやreduxのcompose関数などの関数の組み合わせに対応したライブラリを利用して、よりエレガントにネスト化されたHOCを処理することができます。例えば、従来の入れ子になったHOCをサンプルに、react-reduxのconnect関数を加えて、合計3つのHOCを入れ子にして使用するとしよう。compose関数を活用すると次のように簡潔に実装できます。

import {compose} from 'redux';
import {connect} from 'react-redux';

// ...

const enhance = compose(
  withMousePosition(mousePositionOptions),
  withWindowScroll(windowScrollOptions),
  connect(mapStateProps, mapDispatchToProps)
);

const EnhancedComponent = enhance(MyComponent);

withWindowScroll関数でこれをサポートする方法は難しくないでしょう。既存のコンポーネントを返却するロジックを関数にして、もう一度囲めばよいですね。

function withWindowScroll({wait = 0, mapProps = identity} = {}) {
  return function(WrappedComponent) {
    return class extends React.Component {
       // クラスコードは従来と同じ
    }
  }
}

デバッグ – Display Name

ある意味ではHOCの欠点の1つに見做されるが、上記の例のようにコードを作成すると、実際のHOCによって返されるコンポーネントは、名前のない匿名コンポーネントとなります。これをReact開発ツールで確認すると次のようになります。

このように_class3_class2などの名前ではデバッグするとき、どのようなコンポーネントなのか、正確な情報が分かりにくいでしょう。そこでReactでは、HOCで返されるコンポーネントに対してdisplayNameを指定するコンベンションを定めています。

上図でreact-reduxのconnect関数が返すコンポーネントはConnect(_class3)という名前だと示されます。このように大文字で始まるHOCの名前と括弧内に適用されるコンポーネントのdisplayNameを合わせて表示するのがコンベンションです。withWindowScroll関数に適用するには、次のように作成します。

// コンポーネントのdisplayNameが指定されている場合があり
// このようなヘルパー関数が別途必要となる
function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

function withWindowScroll({wait = 0, mapProps = identity} = {}) {
  return function(WrappedComponent) {
    return class extends React.Component {
      // 現在のstaticフィールド文法はStage 2である
      // 使用できない環境では外部からクラスのスタティスティカルメンバーを直接指定する
      static displayName = `WithWindowScroll(${getDisplayName(WrappedComponent)})`;

      // 他のコードは従来と同じ
    }
  }
}

コードが少し複雑になったが、実のところ、これらは守らなくてもよいです。しかし今後のデバッグをしやすくするためには、コンベンションに従った方が有益です。このように修正すると開発ツールでは次のようになります。

おわりに

2部では、単純なHOCに少しずつ機能を追加しながら、いくつかの手法とコンベンションについて説明しました。
ここで取り扱った内容を熟知すれば、実際のプロジェクトでも問題なくHOCを活用できるでしょう。

HOCの活用方法については長所のみ説明をしましたが、実際のところ様々な欠点もあります。shallowレンダリングを重ねているHOCをテストする場合や、WrappedComponentの静的メンバーを使用するときは、追加作業が必要であり、レンダリングの際にWrappedComponentから必要な情報を受け取り、処理することも容易ではありません。

このような理由から、最近ではRender Propsへの関心が高まっており、先日React公式ホームページにも Render Props関連記事が追加されました。さらに16.3.0から変更されたContext APIがRender Props形式を使ってさらに力をつけています。

しかし個人的には、Render PropsがHOCよりも良い概念ということではなく、2つの手法はそれぞれの長所と短所を併せ持っていると思います。HOCを適切に使用すればReactで重複したコードをきれいに除去することができ、コンポーネントの組み合わせから、より柔軟な構造を作ることができるでしょう。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop