Higher-Order Components と Recompose の初歩

Higher-Order Components(以下、HOC)は、Reactのコンポーネントを作る際のパターン。
HOCを使うことで、複数のコンポーネントで使っている処理を共通化したり、SFCにライフサイクルメソッドを追加したりすることが出来る。

基本的な構造

HOCは、以下のような関数を使って実現する。

function hocFactory(WrappedComponent) {
  return class extends React.Component {
    render() {
      return <WrappedComponent />;
    }
  };
}

コンポーネントを引数として受け取り、それに機能を追加した新しいコンポーネントを返す。
上記の例では何もしていないが、hocFactory(ファクトリ関数)のなかで様々な処理を行うことで、WrappedComponentに新しい機能を加えることが出来る。

このエントリでは、HOCを使った以下のテクニックについて説明する。

  • propsをファクトリ関数から受け取る
  • SFCにライフサイクルメソッドを追加する

propsをファクトリ関数から受け取る

propsとしてtextを受け取りそれを表示する以下のようなコンポーネントがあるとする。

function Basic({text}) {
  return <div>{text}</div>;
}

<Basic text="abc" />とすれば、abcと表示される。
同じことをHOCでやる場合、次のようになる。

function hocFactory(WrappedComponent) {
  return class extends React.Component {
    render() {
      return <WrappedComponent text="abc" />;
    }
  };
}
const EnhancedBasic = hocFactory(Basic);

こうすると、<Basic text="abc" /><EnhancedBasic />は同じ内容になり、どちらもabcと表示される。

もちろんこの例の場合、HOCを使う意味はないし、むしろ煩雑になっているだけである。
だが異なるコンポーネントに共通の機能を渡したいケースなどでは、このテクニックが役に立つ。

インライン要素のテキストを表示するTextContentと、リストを表示するListContentという2つのコンポーネントを用意して、それを使いながら説明してく。

HOCを使わない場合

TextContentListContentの内容は次の通り。

function TextContent({text}) {
  return (
    <span>
      {text}
    </span>
  );
}

function ListContent({contents}) {
  return (
    <ul>
      {contents.map(i => <li key={i}>{i}</li>)}
    </ul>
  );
}

以下のようにすることで、それぞれテキストとリストが表示される。

<TextContent text="xyz" />
<ListContent contents={['abc', '123', 'def']} />

このコンポーネントに、以下の機能を追加することになった。

  • 要素をクリックすることで、文字の色が変わる
  • TextContentは、黒⇔赤
  • ListContentは、黒⇔オレンジ

以下が、これを実装したもの。

class TextContent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {color: 'black'};
    this.changeColor = this.changeColor.bind(this);
  }
  changeColor() {
    this.setState({color: this.state.color === 'black' ? 'red' : 'black'});
  }
  render() {
    const {color} = this.state;
    return (
      <span style={{color}} onClick={this.changeColor}>
        {this.props.text}
      </span>
    );
  }
}

class ListContent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {color: 'black'};
    this.changeColor = this.changeColor.bind(this);
  }
  changeColor() {
    this.setState({color: this.state.color === 'black' ? 'orange' : 'black'});
  }
  render() {
    const {color} = this.state;
    return (
      <ul style={{color}} onClick={this.changeColor}>
        {this.props.contents.map(i => <li key={i}>{i}</li>)}
      </ul>
    );
  }
}

機能としては問題ないが、一目見て分かる通り、ほとんど同じ内容が重複して記述してある。
HOCを使うことで、効率よく記述することが可能になる。

HOCを使って書き直す

まず、TextContentListContentをシンプルな形に戻す。
そして、文字の色はprops.colorとして受け取り、クリックによる文字色の変更機能はprops.onClickとして受け取るようにした。

function TextContent({text, color, onClick}) {
  return (
    <span style={{color}} onClick={onClick}>
      {text}
    </span>
  );
}

function ListContent({contents, color, onClick}) {
  return (
    <ul style={{color}} onClick={onClick}>
      {contents.map(i => <li key={i}>{i}</li>)}
    </ul>
  );
}

<Basic />の例で示したように、HOCのファクトリ関数はpropsをコンポーネントに渡すことが出来るのだから、それを利用すればいい。

以下が、HOCを使って実装したバージョン。
共通の処理をファクトリ関数にまとめている。

function hocFactory(WrappedComponent, color1, color2) {
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.state = {color: color1};
      this.changeColor = this.changeColor.bind(this);
    }
    changeColor() {
      this.setState({color: this.state.color === color1 ? color2 : color1});
    }
    render() {
      return (
        <WrappedComponent
          {...this.props}
          color={this.state.color}
          onClick={this.changeColor}
        />
      );
    }
  };
}

const EnhancedTextContent = hocFactory(TextContent, 'black', 'red');
const EnhancedListContent = hocFactory(ListContent, 'black', 'orange');
<EnhancedTextContent text="xyz" />
<EnhancedListContent contents={['abc', '123', 'def']} />

注意点としては、以下の{...this.props}を忘れないこと。
これを記述しないと、新しく作られたコンポーネントに渡されたprops(この例ではtextcontents)が反映されない。

<WrappedComponent
  {...this.props}
  color={this.state.color}
  onClick={this.changeColor}
/>

また、{...this.props}のあとにpropを定義すると、その内容で上書きされる。
そのため、以下のように定義すると、<EnhancedTextContent text="xyz" />としてもxyzではなくfooと表示される。

<WrappedComponent
  {...this.props}
  text="foo"
  color={this.state.color}
  onClick={this.changeColor}
/>

this.props.children

ちなみにこのケースでは、HOCを使わずthis.props.childrenを使った書き方でも実装できる。

function TextContent({text}) {
  return (
    <div>
      {text}
    </div>
  );
}

function ListContent({contents}) {
  return (
    <ul>
      {contents.map(i => <li key={i}>{i}</li>)}
    </ul>
  );
}

class Wrapper extends React.Component {
  constructor(props) {
    super(props);
    this.state = {color: this.props.color1};
    this.changeColor = this.changeColor.bind(this);
  }
  changeColor() {
    const {color1, color2} = this.props;
    this.setState({color: this.state.color === color1 ? color2 : color1});
  }
  render() {
    const {color} = this.state;
    return (
      <div style={{color}} onClick={this.changeColor}>
        {this.props.children}
      </div>
    );
  }
}
<Wrapper color1="black" color2="red">
  <TextContent text="xyz" />
</Wrapper>
<Wrapper color1="black" color2="orange">
  <ListContent contents={['abc', '123', 'def']} />
</Wrapper>

HOCとどちらを採用すべきかは、状況によるのだと思う。

SFCにライフサイクルメソッドを追加する

Reactのコンポーネントを定義する際は、出来るだけSFC(Stateless Functinal Componenens)で定義するのが望ましいとされる。
SFCはstateを持たないため、コンポーネントをステートレスに保てるからだ。
しかしSFCには、componentDidMountなどのライフサイクルメソッドを利用できないという欠点がある。

しかしHOCを使うことで、SFCでもライフサイクルメソッドを利用できるようになる。

function Basic({text}) {
  return <div>{text}</div>;
}

function hocFactory(WrappedComponent) {
  return class extends React.Component {
    componentDidMount() {
      console.log('componentDidMount');
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
}

const EnhancedBasic = hocFactory(Basic);

こうすると、EnhancedBasicをマウントしたときにログが表示されるようになる。

recompose を使う

HOCによるライフサイクルメソッドの追加は、Recomposeというライブラリを使うことによって、より簡単に記述できる。

https://github.com/acdlite/recompose

先程のEnhancedBasicを定義する場合、次のように書く。

import React from 'react';
import {lifecycle} from 'recompose';

function Basic({text}) {
  return <div>{text}</div>;
}

const EnhancedBasic = lifecycle({
  componentDidMount() {
    console.log('componentDidMount');
  },
})(Basic);

lifecycleのなかでsetStateを使うことも出来る。
そしてstateは、元のコンポーネントにpropsとして渡される。

そのため以下のように書くと、componentWillMountcomponentDidMountの時間差を表示するコンポーネントが作られる。

function Basic({time}) {
  return <div>{time}</div>;
}

const EnhancedBasic = lifecycle({
  componentWillMount() {
    this.setState({time: new Date().getTime()});
  },
  componentDidMount() {
    this.setState({time: new Date().getTime() - this.state.time});
  },
})(Basic);

なおRecomposeには、lifecycle以外にもHOCを便利に使うための様々な機能が用意されている。
https://github.com/acdlite/recompose/blob/master/docs/API.md

参考資料

Fetch API を Stub にしてテストする

SPAなどは特にそうだと思うけど、fetchを使って非同期にAPIを叩き、その結果に基いて部分的にビューを書き換える、というのはよく行われる。

そういう時に面倒なのがテスト。
fetchwindowオブジェクトに入っているからテスト環境には存在しないし、そもそもAPIを叩こうにもクロスオリジンやら何やらで上手くいかないことが多い。

イベントが発火した際にAPIを叩きに行くか、APIからのレスポンスに基いてビューが書き換えられるか、ということをテストしたい場合、いちいちAPIと通信できるように環境を整えるのは手間が掛かり過ぎる。
こういうケースでは、fetchをスタブにしてしまったほうがよい。

テスト対象

以下のReactのコンポーネントについて、テストを書いていく。

import React from 'react';

export default class ApiRequest extends React.Component {
  constructor() {
    super();
    this.state = {result: 'ボタンを押してください。'};
    this.callApi = this.callApi.bind(this);
  }
  callApi(statusCode) {
    fetch(`https://httpbin.org/status/${statusCode}`).then(res => {
      this.setState({
        result: res.status >= 200 && res.status < 300 ? '成功' : '失敗',
      });
    });
  }
  render() {
    const {result} = this.state;
    return (
      <div>
        <button id="200" onClick={() => this.callApi(200)}>
          200を叩く
        </button>
        <button id="500" onClick={() => this.callApi(500)}>
          500を叩く
        </button>
        <div id="result">{result}</div>
      </div>
    );
  }
}

ボタンを押すとAPIを叩きに行き、そのレスポンスに応じてthis.stateを変更し、ビューが書き換わる。
仕組みとしては、よくあるものだと思う。

fetch がないと言われる

まずはこんな感じで書いてみる。

import assert from 'assert';
import React from 'react';
import {shallow} from 'enzyme';

import ApiRequest from '../ApiRequest';

describe('ApiRequest', () => {
  let wrapper;
  beforeEach(() => {
    wrapper = shallow(<ApiRequest />);
  });
  it('最初は「ボタンを押してください。」と表示されている。', () => {
    assert(wrapper.find('#result').text() === 'ボタンを押してください。');
  });
  it('200のボタンを押下すると「成功」と表示される。', () => {
    wrapper.find('#200').simulate('click');
    assert(wrapper.find('#result').text() === '成功');
  });
  it('500のボタンを押下すると「失敗」と表示される。', () => {
    wrapper.find('#500').simulate('click');
    assert(wrapper.find('#result').text() === '失敗');
  });
});

当然、失敗する。

  ApiRequest
    ✓ 最初は「ボタンを押してください。」と表示されている。
    1) 200のボタンを押下すると「成功」と表示される。
    2) 500のボタンを押下すると「失敗」と表示される。


  1 passing (37ms)
  2 failing

  1) ApiRequest 200のボタンを押下すると「成功」と表示される。:
     ReferenceError: fetch is not defined
  2) ApiRequest 500のボタンを押下すると「失敗」と表示される。:
     ReferenceError: fetch is not defined

なのでまず、グローバルオブジェクトにfetchを作る。
これは、個別のテストファイルに書くのではなく、設定ファイルか何かに書くのが一般的だと思う。

global.fetch = () => {};

もちろんこれだけではテストは通らず、TypeError: Cannot read property 'then' of undefinedと言われる。
ここから、fetchをスタブにしていく。

fetch をスタブにする

先程のテストコードを以下のように書き換えると、テストが動くようになる。

import assert from 'assert';
import React from 'react';
import {shallow} from 'enzyme';
import sinon from 'sinon';

import ApiRequest from '../ApiRequest';

describe('ApiRequest', () => {
  let wrapper;
  let stub;
  beforeEach(() => {
    wrapper = shallow(<ApiRequest />);
    stub = sinon.stub(global, 'fetch');
  });
  afterEach(() => {
    stub.restore();
  });
  it('最初は「ボタンを押してください。」と表示されている。', () => {
    assert(wrapper.find('#result').text() === 'ボタンを押してください。');
  });
  it('200のボタンを押下すると「成功」と表示される。', done => {
    stub.returns(Promise.resolve({status: 200}));
    wrapper.find('#200').simulate('click');
    setTimeout(() => {
      assert(wrapper.find('#result').text() === '成功');
      done();
    }, 0);
  });
  it('500のボタンを押下すると「失敗」と表示される。', done => {
    stub.returns(Promise.resolve({status: 500}));
    wrapper.find('#500').simulate('click');
    setTimeout(() => {
      assert(wrapper.find('#result').text() === '失敗');
      done();
    }, 0);
  });
});

fetchはPromiseベースなので、スタブにして、FulFilledなpromiseオブジェクトを返すようにすればいい。
Promiseを使っているため、doneを使わないと上手く動かないので注意が必要。

sinonやスタブについてはこちらを参照。

doneを使って非同期テストについてはこちらを参照。