React の Higher-order Components の利用方法


前書き

この資料は2016/12/15 に行われた【ランサーズ×Mozilla×freee】React実践!勉強会で発表した際の資料を改修したものです。

(qitta に投稿するのが初めての場合、2MBまでしか画像をアップできないので、編集してます)

間違いなどがあれば、コメントや編集リクエストなどをいただければありがたいです。



目次


  • 対象読者

  • HOC とは何か?

  • HOC の基礎

  • 実際に利用されているライブラリについて

  • HOC の具体的な実装例



対象読者


  • Reactを利用してアプリケーションを作成したことがある人

  • ReduxやFluxなどのフレームワークを利用したことがある人



HOC とは何か?

A higher-order component is just a function that takes an existing component and returns another component that wraps it.

(引用:Mixins Are Dead. Long Live Composition)


  • コンポーネントを引数にして、カスタマイズしたコンポーネントを返す関数のこと

  • 既存のコンポーネントに新しい機能を追加することが可能



HOCの基礎

sebmarkbage/Enhance.jsの例


Enhance.js

import { Component } from "React";

// 関数の引数にコンポーネントを受け取る
// function Enhance(ComposedComponent) { return xxx } と同じ
export var Enhance = ComposedComponent => class extends Component {
constructor() {
this.state = { data: null };
}
componentDidMount() {
this.setState({ data: 'Hello' });
}
render() {
// 引数で受け取った ComposedComponent に props を渡して返す
return <ComposedComponent {...this.props} data={this.state.data} />;
}
};


  • 引数に受け取った ComposedComponent に props などをマージして return



要点を抜き出すと

image



利用する側の実装


HigherOrderComponent.js

import { Enhance } from "./Enhance";

class MyComponent {
render() {
// MyCompoent で Enhancer の data を参照できるようになった
if (!this.data) return <div>Waiting...</div>;
return <div>{this.data}</div>;
}
}

export default Enhance(MyComponent); // Enhanced component



  • Enhance(MyComponent) で MyComponent に HOC を適応

  • 関数でラップすることで、 data というプロパティが新たに追加された



要点を抜き出すと

image



HOCの基礎:イメージ

image

- コンポーネントに関数を適応し、機能が合成されたコンポーネントを返す

- propsを新しく加えたり、ライフサイクルメソッドを追加することも可能



最近よく見かける HOC を利用しているライブラリとその実装



material-uiでの例


  • Google Material Design を react の component で利用出来るライブラリ
    image



  • material-ui / themes

  • テーマの色設定などは、muiTheme という形で1つのファイルで管理している

  • textColor などの定義を、自作のコンポーネントでも使いたい場合・・・

  • muiThemeable という HOC を呼び出す


muiTheme.js

import { cyan500, pinkA200, darkBlack } from '../colors';

import spacing from '../spacing';

export default {
spacing: spacing,
fontFamily: 'Roboto, sans-serif',
palette: {
primary1Color: cyan500,
accent1Color: pinkA200,
textColor: darkBlack,
// 省略...
},
};




material-ui / muiThemeable


  • マテリアルUIの textColor などの定義を、自作のコンポーネント内で呼び出す


muiThemeableを使ってコンポーネント内でThemeを呼び出す

import React from 'react';

import muiThemeable from 'material-ui/styles/muiThemeable';

const MyComponent = (props) => (
<span style={{color: props.muiTheme.palette.textColor}}>
Hello World!
</span>
);

export default muiThemeable()(MyComponent);



  • muiThemeable()(MyComponent)の部分がHOC

  • props に muiTheme.palette.textColor が追加されて、自作のコンポーネント内でも扱えるようになった



muiThemeable.js

import React, {PropTypes} from 'react';

import getMuiTheme from './getMuiTheme';

export default function muiThemeable() {
// 省略...
return (Component) => {
const MuiComponent = (props, context) => {
const {muiTheme = getMuiTheme()} = context;

return <Component muiTheme={muiTheme} {...props} />;
};
// 省略...
return MuiComponent;
};
}


Component を引数にとって、 muiTheme という props を持った MuiComponent という新しいコンポーネントを return している



形としてみると

image



ReduxForm での例


  • ReduxForm

  • 入力フォームなどを redux のフローで扱ってくれるライブラリ
    image



ReduxForm でHOCを利用する

簡単なプロフィールフォームの例

import React, { Component } from 'react';

import { reduxForm, Field } from 'redux-form';

class ProfileForm extends Component {
render() {
const { handleSubmit } = this.props;
return (
<form onSubmit={handleSubmit}>
<Field name="firstName" component="input" type="text"/>
</form>
);
}
}

ProfileForm = reduxForm({
form: 'profile'
})(ProfileForm);

export default ProfileForm;

reduxForm({})(ラップされるコンポーネント)

という構成。



ReduxForm でHOCを利用する

image


import { Component, createElement } from 'react'

// 省略...
const createReduxForm = structure =>
// 省略...
return WrappedComponent => {
// 省略...
return class ReduxForm extends Component {
render() {
return createElement(WrappedComponent, {})
}
}
}
}
export default createReduxForm



要点を抜き出すと

image



ここまでのまとめ1

React 関連のライブラリを使っていて

Enhancer(WrappedComponent)

というような、コンポーネントを引数に取る構成の記述を見かけた場合は HOC の実装を使っているのではないかと考えると理解しやすい。



ここまでのまとめ2


  1. 関数がコンポーネントを引数にとって

  2. 何らかの処理を加えて


    • 新しい props の注入

    • 共通のライフサイクル処理



  3. 引数にとったコンポーネントに合成して返す

案外、 HOC の実装はパッと見で複雑でも、元のコンポーネントに新しく props を注入したりしているだけかもしれません。



HOC の 2 つのパターン

React Higher Order Components in depthの記事によると、HOC には2つのパターンがあります


  1. 先ほどまでの props を新しく追加する例は Props Proxy

  2. PropsProxy 以外には Inheritance Inversion

実際の実装を見ながら順に説明していきます。



HOC の具体的な実装例1


  • ボタンのバリエーションを HOC で表現する

  • 以下のようなプライマリカラーの効いたボタンを作りたいとき
    image



HOC の具体的な実装例1


  • createPrimaryButton という関数で、HOCを作成

  • material-ui の RaisedButton コンポーネントを拡張


プライマリボタンを作成する例

import React, { Component } from 'react';

import RaisedButton from 'material-ui/RaisedButton';

function createPrimaryButton(WrappedComponent) {

return class designedButtonComponent extends Component {

render() {
return (<WrappedComponent {...this.props} primary={true} />);
}
}
}
export const PrimaryButton = createPrimaryButton(RaisedButton);




HOC の具体的な実装例1: HOC を作成

image


  • コンポーネントに与えられた props をそのまま受け渡しつつ、新しく primary={true} という props を追加する



HOC の具体的な実装例1: 呼び出す側


  • PrimaryButton という名前で呼び出すことで、利用者側も理解しやすいコードになる

  • material-ui の label などのプロパティも同時に利用出来る


ボタンの利用例

import { PrimaryButton } from './buttons';

export const renderSomePageWithButton = buttonAction =>
<div>
<PrimaryButton
label={'プライマリボタン'} //ボタンに表示するテキスト
onTouchTap={buttonAction}
/>
</div>



HOC の具体的な実装例1


  • createButton に引数をつけて、バリエーションを増やす

  • 先ほどの HOC に color, size, shape などの引数を追加


buttons.js

import React, { Component }from 'react';

import RaisedButton from 'material-ui/RaisedButton';
import { buttonColor, buttonSize, buttonShape } from './button_styles';

function createButton(WrappedComponent, color, size, shape) {
return class designedButtonComponent extends Component {
render() {
return (<WrappedComponent
{...this.props}
{...buttonColor[color]}
{...buttonSize[size]}
{...buttonShape[shape]}
/>);
}
}
}
export const PrimaryButtonFullWidth = createButton(RaisedButton, 'primary', 'fullWidth', 'original');
export const SecondaryButtonRounded = createButton(RaisedButton, 'secondary', 'original', 'round');
export const AccentButtonHalfWidth = createButton(RaisedButton, 'accent', 'halfWidth', 'original');




HOC の具体的な実装例1: 呼び出す側


ボタンのバリエーションの利用例


import {
PrimaryButtonFullWidth,
AccentButtonHalfWidth,
NSecondaryButtonRounded } from './buttons';
export const renderSomePageWithButton = buttonAction =>
<div>
<NPrimaryButtonFullWidth label={'プライマリボタン幅MAX'} />
<NSecondaryButtonRounded label={'角丸セカンダリボタン'} />
<NAccentButtonHalfWidth label={'アクセントボタン幅半分'} />
</div>



HOCを使ったコンポーネントのパターン実装のメリット


  • HOC を用いれば、コンポーネントに新しく props を追加できる


  • PrimaryButtonFullWidthなど、限定的な名前を持たせることで、コードがリーダブルになる

  • FlowTypeなどで型を持たせると、さらにリーダブルになる

// @flow

type ColorType =
'primary'
| 'secondary'
| 'accent'
;
function createButton(WrappedComponent: any, color:ColorType) {...}



 HOC の具体的な実装例2


  • 閲覧するのにログインが必要なコンポーネントをHOCで制御

  • Redux で、 session.userId でログインユーザを管理している前提

  • loggedInRequired()というHOCを作ってみる



 HOC の具体的な実装例2: 利用する側


  • ログインが必要な箇所は、HOCでラップしてあげる

  • 「ログインしていません」のようなダイアログを出したい


ラップされる側のコンポーネントの利用イメージ

import loggedInRequired from '../loggedInRequired.js'

// 省略...
class ProjectContainer extends Component {
// 省略...
}
export default loggedInRequired(
connect(mapStateToProps, mapDispatchToProps)
(ProjectContainer)
);



 HOC の具体的な実装例2: HOCを作成する側

export default function loggedInRequired(WrappedComponent) {

class loggedInRequiredComponent extends WrappedComponent {

componentWillMount() {
// react-redux のステートには this.store.getState() でアクセスできます
if (!this.store.getState().session.userId) {
// ログインしていないユーザが見た時のアクションを書く
}
}
render() {
if (!this.store.getState().session.userId) {
// ログインしていないユーザが閲覧したらレンダリングしない
return null;
}
return super.render();
}
}
return loggedInRequiredComponent;
}


  • さっきまでと異なるのは、 WrappedComponent を return するのではなく、 extends している点

  • Inheritance Inversion というパターン


  • react-redux のステートには this.state.storeState でアクセスできます -> this.store.getState() に修正しました。(react-redux v5系以降)



HOC の具体的な実装例2で行っていること

ラップしたコンポーネント自身を extends している。(Inheritance Inversion)


  • HOCのライフサイクルメソッドを、ラップしたコンポーネントに適応

  • HOCから、ラップしたコンポーネントの state や props に thisでアクセス

  • 条件によって、レンダリングしないように、 render をハイジャックできる

ログインが必要なコンポーネントは、このHOCを利用すれば良いため、他のコンポーネント内にif文などを書かなくて良くなり、コンポーネント自身の責務に集中出来る。



おまけ:複数のHOCを合成して使うテクニック

コンポーネントを複数のHOCでラップしたい場合

- loggedInRequired(connect(mapStateToProps, mapDispatchToProps)(ProfileForm)) のように書くのか?

- redux の compose や lodash.js の _.compose を利用するとスッキリかけます

import { compose } from 'redux';

import { reduxForm } from 'redux-form';

class ProfileForm extends Component {
...
}
ProfileForm = compose(
loggedInRequired,
connect(mapStateToProps, mapDispatchToProps),
reduxForm({ form: 'ProfileForm' })
)(ProfileFormContainer)

export default ProfileForm



まとめ


  • HOC はコンポーネントを引数にして、カスタマイズしたコンポーネントを返す関数のこと

  • material-ui や redux-form など、様々なライブラリで利用されている

  • 組み合わせることで、既存のコンポーネントに新しい機能を追加することが可能

  • アプリケーション内で共通に利用する機能や、パーツをまとめるのに便利

  • 慣れればコードを読みやすくかける



参考資料