コンポーネントの横断的関心事を処理する方法 Mixins, HOCs, Render Props, Hooks を振り返る

これは何か

複数のコンポーネントが必要とする機能を共有可能にする方法を整理してみました。プロコンとこれから。基本的には 参考 URLs にあるドキュメントの切り貼りです。

キーワード

横断的関心事とは

コンポーネントは React のコード再利用における基本単位です。しかし、いくつかのパターンの中には、これまでのコンポーネントが素直に当てはまらないことがあることに気づいたかもしれません。

https://ja.reactjs.org/docs/higher-order-components.html

具体例として以下のようなコンポーネントを考えてみます。

class CommentList extends React.Component {
  constructor(props) { /* ... */ }
  componentDidMount() {
    // Subscribe to changes
    DataSource.addChangeListener(this.handleChange);
  }
  componentWillUnmount() {
    // Clean up listener
    DataSource.removeChangeListener(this.handleChange);
  }
  handleChange() {
    // Update component state whenever the data source changes
    this.setState({
      comments: DataSource.getComments()
    });
  }
  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/higher-order-components.html

上のコードは CommentList だけですが、もう片方のコンポーネントも大部分の実装は同じになります。

  • コンポーネントのマウント時に DataSource にイベントリスナを登録
  • リスナの内部で setState をデータソースが変更されるたびに呼び出す
  • コンポーネントのアンマウント時にはイベントリスナを削除

横断的関心事とは、このような複数のコンポーネントに共通しているが1箇所にまとめるのが難しい関心事のことです。横断的関心事のロジックを1つの場所に定義し、複数のコンポーネントで共有可能にするために Mixins, HOCs, Render Props, Hooks といったテクニックが有用なことがあります。

Higher-Order Components

高階関数は「引数として関数をとる」「関数を戻り値として返す」関数です。では高階コンポーネントとはどういうものでしょうか。

具体的には、高階コンポーネントとは、あるコンポーネントを受け取って新規のコンポーネントを返すような関数です

https://ja.reactjs.org/docs/higher-order-components.html

const EnhancedComponent = higherOrderComponent(WrappedComponent);

前述の CommentList コンポーネントを高階コンポーネントを利用してリファクタリングすると以下のようになります。 withSubscription が高階コンポーネントです。

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);
const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);
// https://ja.reactjs.org/docs/higher-order-components.html
function withSubscription(WrappedComponent, selectData) {
  return class extends React.Component {
    constructor(props) { /* ... */ }
    componentDidMount() {
      DataSource.addChangeListener(this.handleChange);
    }
    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }
    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }
    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}
// https://ja.reactjs.org/docs/higher-order-components.html

より具体的な説明:

HOC は入力のコンポーネントを改変したり、振る舞いをコピーするのに継承を利用したりしません。むしろ HOC は元のコンポーネントをコンテナコンポーネント内にラップすることで組み合わせるのです。HOC は副作用のない純関数です。

... 外側にある HOC は渡すデータが使われる方法や理由には関心がありませんし、ラップされたコンポーネントの側はデータがどこからやって来たのかには関心を持ちません。

https://ja.reactjs.org/docs/higher-order-components.html

アンチパターンとして:

コンポーネントの改変を行うような HOC は不完全な抽象化です。つまり、利用する側は他の HOC との競合を避けるため、どのように実装されているかを知っておく必要があるのです。

https://ja.reactjs.org/docs/higher-order-components.html

Vue と Higher-Order Components

高階コンポーネントフレームワークが提供する機能ではなく汎用的な考え方なので、他のフレームワークでも実現可能です。

Render Props

“レンダープロップ (render prop)”という用語は、値が関数である props を使って、コンポーネント間でコードを共有するためのテクニックを指します。

... より具体的に述べると、レンダープロップとは、あるコンポーネントが何をレンダーすべきかを知るために使う関数の props です。

https://ja.reactjs.org/docs/render-props.html

<DataProvider render={data => (
  <h1>Hello {data.target}</h1>
)}/>

具体例として「マウスに追従する猫」を以下の2つにわけて実装してみます。

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse render={mouse => (
          <Cat mouse={mouse} />
        )}/>
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/render-props.html
class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse;
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}
class Mouse extends React.Component {
  constructor(props) { /* ... */ }
  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }
  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        {/*
          Instead of providing a static representation of what <Mouse> renders,
          use the `render` prop to dynamically determine what to render.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}
// https://ja.reactjs.org/docs/render-props.html

Vue の Scoped Slots

Higher-Order Components と同様に Rendre Props も機能 (API) ではなく考え方です。それゆえ Vue でも同様な考え方を利用してコンポーネントのロジック共有が可能です。

通化させたい振る舞いをもつコンポーネントに何をレンダーさせるか動的に決定させる方法として Vue の場合 Scoped Slots を利用することができます。

In case you are wondering what’s the equivalent pattern in Vue, it’s called scoped slots (and if using JSX it works the same as React)

https://twitter.com/youyuxi/status/912422434300710912

以下は VeeValidate の ValidationProvider を利用した実装例です。

<ValidationProvider name="name" rules="required" v-slot="{ errors }">
  <input v-model="name">
  <p>{{ errors[0] }}</p>
</ValidationProvider>

Render Props と Higher-Order Components

ミックスインの問題を解決させる方法として HOCs が広まったようですが、HOCs にもいくつかの問題があったようです。以下は Reneder Props でそのあたりも解決できるよ、という話。

This technique avoids all of the problems we had with mixins and HOCs:

  • Indirection. We don’t have to wonder where our state or props are coming from. We can see them in the render prop’s argument list.
  • Naming collisions. There is no automatic merging of property names, so there is no chance for a naming collision.

...

Additionally, the composition model here is *dynamic*!

Use a Render Prop! - componentDidBlog

Mixins

コンポーネントのロジック共有のテクニックとしてミックスインがどうして利用されなくなったのかでしょうか。

以前に横断的関心事を処理する方法としてミックスインをお勧めしました。私たちはその後にミックスインはそれが持つ価値以上の問題を引き起こすことに気づきました。ミックスインから離れる理由と、既存のコンポーネントを移行する方法についてはこちらの詳細な記事を読んでください

https://ja.reactjs.org/docs/higher-order-components.html

上の引用にもある Mixins Considered Harmful – React Blog がよくまとまっていました。私が利用していたのは Vue のミックスインですが、ほぼ共感できる内容でした。以下は記事内の "Why Mixins are Broken" より:

  • Mixins introduce implicit dependencies
    • 暗黙的な依存関係が生まれてしまう。安全にコードを変更することが難しくなるし、新しいメンバーが理解するハードルもあがる。
    • コンポーネントがミックスインのメソッドを呼び出すこともあれば、ミックスインがコンポーネントのメソッドを呼び出すこともある。
    • コンポーネントとは違いミックスインの間には階層構造がなくフラットな関係なため、データの流れや依存関係がどのようになっているのか把握するのが難しくなる。
  • Mixins cause name clashes
    • 例えば適用させる複数のミックスインが同じメソッド名を持つ場合
  • Mixins cause snowballing complexity

Mixins => HOCs => Render Props => Hooks

フックは React 16.8 から追加された React の API です。フックを導入した動機が フックの導入 – React に書かれています。

ステートフルなロジックをコンポーネント間で再利用するのは難しい

... React をしばらく使った事があれば、この問題を解決するためのレンダープロップ高階コンポーネントといったパターンをご存じかもしれません。しかしこれらのパターンを使おうとするとコンポーネントの再構成が必要であり、面倒なうえにコードを追うのが難しくなります。典型的な React アプリを React DevTools で見てみると、おそらくプロバイダやらコンシューマやら高階コンポーネントやらレンダープロップやら、その他諸々の抽象化が多層に積み重なった『ラッパー地獄』を見ることになるでしょう。... React にはステートフルなロジックを共有するためのよりよい基本機能が必要なのです。

フックを使えば、ステートを持ったロジックをコンポーネントから抽出して、単独でテストしたり、また再利用したりすることができます。フックを使えば、ステートを持ったロジックを、コンポーネントの階層構造を変えることがなく再利用できるのです。このため、多数のコンポーネント間で、あるいはコミュニティ全体で、フックを共有することが簡単になります。

フックの導入 – React

Render Props や HOCs との関係についても書かれています。

フックはレンダープロップや高階コンポーネントを置き換えるものですか?

レンダープロップや高階コンポーネントは、ひとつの子だけをレンダーすることがよくあります。フックはこのようなユースケースを実現するより簡単な手段だと考えています。これらのパターンには引き続き利用すべき場面があります(例えば、バーチャルスクローラーコンポーネントrenderItem プロパティを持つでしょうし、コンテナコンポーネントは自分自身の DOM 構造を有しているでしょう)。とはいえ大抵の場合ではフックで十分であり、フックがツリーのネストを減らすのに役立つでしょう。

フックに関するよくある質問 – React

ロジック共有のためにコンポーネントを利用する必要なんてないんですよ。

Vue Composition API

Vue に React Hooks のような関数ベースのロジック合成 API は追加されるのでしょうか。

( ´・ω) (´・ω・) (・ω・`) (ω・` ) ネー

Composition API RFC | Vue Composition API:

ここで詳しい情報を確認できるようです。

Motivation

Logic Reuse & Code Organization

...

Better Type Inference

...

Code Organization

...

But as mentioned in the Motivations section, we believe the Composition API actually leads to better organized code, particularly in complex components. Here we will try to explain why.

What is "Organized Code"?

...

export default {
  setup() { // ...
  }
}

function useCurrentFolderData(networkState) { // ...
}

function useFolderNavigation({ networkState, currentFolderData }) { // ...
}

function useFavoriteFolder(currentFolderData) { // ...
}

function useHiddenFolders() { // ...
}

function useCreateFolder(openFolder) { // ...
}

Logic Extraction and Reuse

The Composition API is extremely flexible when it comes to extracting and reusing logic across components.

...

import { ref, onMounted, onUnmounted } from 'vue'

export function useMousePosition() {
  const x = ref(0)
  const y = ref(0)
  function update(e) {
    x.value = e.pageX
    y.value = e.pageY
  }
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  return { x, y }
}

export default {
  setup() {
    const { x, y } = useMousePosition()
    // other logic...
    return { x, y }
  }
}

( ^ω^)

参考 URLs