🔰

React×HOC環境を支援する、Recompose入門

2020/10/03に公開

今回は React Hooks が普及する前の話。
主に関数コンポーネントに機能を付与することに使われる HOC を取り扱う Recompose についてです。

※2022/01/24追記 あくまでライブラリの記事であるとわかりやすくするために、記事タイトルを変更しました(旧:React入門 ~Recompose編~)

Recomposeとは?

公式:GitHub - recompose

前提知識として、React には高階コンポーネント (Higher-Order Component、通称 HOC)という概念があります。
具体的には、あるコンポーネントを受け取って、それに機能を付与した新規のコンポーネントを返すような関数のことを指します。
これまでの記事でも HOC と書いていたものは、これのことでした。

HOC を利用することで、HOC 側にロジック、コンポーネント側はビューといったように責務を分離させたり、ロジック部分を複数のコンポーネントで再利用したりといったことができます。
また、state やライフサイクルを持てない関数コンポーネントに、これらの機能を付与も可能です。

Recompose はこの HOC を扱うユーティリティ的なライブラリです。
以前は多くの方々に利用されていましたが、React 16.8 で追加された React Hooks により、React 本体だけでも同様のことができるようになりました。

そのためライブラリの更新はすでに止まっており、今後は使われなくなっていくのではないかと考えられますが、業務で使用する機会があったので今回記事として書くことにしました。

インストール

yarn add recompose

今回使用するバージョンは0.30.0です。

使い方

以下、記載しているコードは公式サンプルのコードを元にしています。

基本的な使い方

HOC を使う場合、主に以下のような書き方をします。

const enhance = HOC(Component);

ここでの定数名、HOC 名、コンポーネントはあくまで仮のものです。
その中身としては、以下のようなものになります。

  • enhance:機能が追加され、新たに作成されたコンポーネント
  • HOC:引数のコンポーネントに何らかの処置を施す関数
  • Componet:元となるコンポーネント

元となるコンポーネントを HOC でラップするイメージですね。

これを踏まえたうえで、Recompose が提供する HOC を使用した例がこちら。

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

const enhance = withState('counter', 'setCounter', 0);

const Component = enhance(({ counter, setCounter }) => {
  return (
    <div>
      <p>カウンター: {counter}</p>
      <button onClick={() => setCounter(n => n + 1)}>Increment</button>
      <button onClick={() => setCounter(n => n - 1)}>Decrement</button>
    </div>
  );
});

export default Component;

state-counter.gif

上記では state を扱えるようにするwithStateで、ビュー側であるコンポーネントをラップするようになっています。withStateで定義した state と state を更新する関数は props に渡されるので、そこから使用できます。

複数の HOC を併用するやり方

Recompose ではいろんな種類の HOC が提供されているので、それらを併用して使用したい場合もあります。

その場合、普通にやろうとすると以下のようになります。

const enhance = HOC1(HOC2(Component));

実際のコードにするとこんな感じです。
withStatewithHandlersの組み合わせ。

import React from 'react';
import { withState, withHandlers } from 'recompose';

const stateEnhance = withState('counter', 'setCounter', 0);

const handleEnhance = withHandlers({
  incrementCounter: props => () => {
    props.setCounter(v => v + 1)
  },
  decrementCounter: props => () => {
    props.setCounter(v => v - 1)
  },
});

const Component = stateEnhance(handleEnhance((
  { counter, incrementCounter, decrementCounter }) => {
  return (
    <div>
      <p>カウンター: {counter}</p>
      <button onClick={incrementCounter}>Increment</button>
      <button onClick={decrementCounter}>Decrement</button>
    </div>
  );
}));

export default Component;

この例では2つの HOC なのでまだいい方ですが、ここからさらに数が増えるとラップする数が増えて可読性が落ちてえらいことに...。
また、先に書いた HOC から実行されるので、上記のようにwithHandlersのなかでwithStateにて定義したものを使用している場合は、withStateの方を先に書く必要があります。

この可読性の問題を解消するためにはcomposeという関数を使うとよいです。
composeを使うと、以下のように書くことができます。

const enhance = compose(HOC1, HOC2)(Component);

実際のコードだとこんな感じです。
複数の HOC をまとめて書けるので、すっきりしますね。

import React from 'react';
import { compose, withState, withHandlers } from 'recompose';

const enhance = compose(
  withState('counter', 'setCounter', 0),
  withHandlers({
    incrementCounter: props => () => {
      props.setCounter(v => v + 1)
    },
    decrementCounter: props => () => {
      props.setCounter(v => v - 1)
    }
  })
)

const ComposeComponent = enhance(
  ({
    counter,
    incrementCounter,
    decrementCounter
  }) => {
    return (
      <div>
        <p>カウンター:{counter}</p>
        <button onClick={incrementCounter}>Increment</button>
        <button onClick={decrementCounter}>Decrement</button>
      </div>
    )
})

export default ComposeComponent;

Redux との併用

Redux と併用したい場合は、react-redux のconnectを使うとよいです。
このconnectも HOC が使われているそうで、同様にcomposeで他の HOC とまとめて書くことができます。

import React from 'react';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { bindActionCreators } from 'redux';
import { incrementOn, decrementOn } from '../../Actions/Counter';

const enhance = compose(
  connect(
    state => ({
      counter: state.counter
    }),
    dispatch => ({
      actions: bindActionCreators({ incrementOn, decrementOn }, dispatch)
    })
  )
)

const ComposeComponent = enhance(
  ({ counter, actions }) => {
    return (
      <div>
        <p>カウンター:{counter}</p>
        <button onClick={actions.incrementOn}>Increment</button>
        <button onClick={actions.decrementOn}>Decrement</button>
      </div>
    )
})

export default ComposeComponent;

HOC の種類

数が多いので一部のみ紹介。

無駄な再レンダリングを抑制する:pure

props が変更されない限り、コンポーネントが更新されないようにします。
変更されたことを検知するロジックとしてはshallowEqualが使われているようです。

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

const enhance = pure;

const Component = enhance(
  () => {
    return (
      <div>
        <p>pure test</p>
      </div>
    );
});

export default Component;

props を置き換える:mapProps

現在の props を関数が返すものに置き換えます。

mapProps実行後は、num1num2はなくなり、sumという props のみに置き換えられます。

使用例(propsに num1={10}、num={20} 指定)

import React from "react";
import { mapProps } from 'recompose';

const enhance = mapProps(props => {
  return {
    sum: props.num1 + props.num2
  }
})
const Component = enhance(({ num1, num2, sum }) => {
  return (
    <div>
      <p>{num1 ? num1 : 'propsなし'}</p>
      <p>{num2 ? num2 : 'propsなし'}</p>
      <p>{sum}</p>
    </div>
  );
});

export default Component;

map-props.png

props を追加する:withProps

現在の props に関数が返すものを追加します。

使用例(propsに num1={10}、num2={20} 指定)

import React from "react";
import { withProps } from 'recompose';

const enhance = withProps(props => {
  return {
    sum: props.num1 + props.num2
  }
})
const Component = enhance(({ num1, num2, sum }) => {
  return (
    <div>
      <p>{num1 ? num1 : 'propsなし'}</p>
      <p>{num2 ? num2 : 'propsなし'}</p>
      <p>{sum}</p>
    </div>
  );
});

export default Component;

with-props.png

指定した props が変更された時のみ、props を追加する:withPropsOnChange

基本的にはwithPropsと同じであるものの、こちらは指定した props が変更された場合のみ props の追加が行われます。

使用例

import React from "react";
import { withPropsOnChange } from 'recompose';

const enhance = withPropsOnChange(['num'], props => {
  return {
    sum: props.num * 2
  }
})
const Component = enhance(({ num, sum }) => {
  return (
    <div>
      <p>{num ? num : 'propsなし'}</p>
      <p>{sum ? sum : 'propsなし'}</p>
    </div>
  );
});

export default Component;

propsにnumを指定しなかった場合
with-props-on-change-no-props.png

propsにnumを指定した場合
with-props-on-change.png

props のデフォルト値を指定する:defaultProps

React 本体で使用できるdefaultPropsプロパティとほぼ同じことができるものの、厳密には違う模様。
なお、コンポーネント呼び出し時に対象の props が指定されていた時は、そちらが優先して使われます。

使用例(props に指定なし)

import React from "react";
import { defaultProps } from 'recompose';

const enhance = defaultProps({
  text: 'default'
})

const Component = enhance(({ text }) => {
  return (
    <div>
      <p>{text}</p>
    </div>
  );
});

export default Component;

default-props.png

props の名前を変更する:renameProp

第1引数の名称の props を第2引数の名称にリネーム。
この HOC 1つにつき、1つしか書けません。

使用例(props にtext="テスト"を指定)

import React from "react";
import { renameProp } from 'recompose';

const enhance = renameProp('text', 'renameText');

const Component = enhance(({ text, renameText }) => {
  return (
    <div>
      <p>{text ? text : 'propsなし'}</p>
      <p>{renameText ? renameText : 'propsなし'}</p>
    </div>
  );
});

export default Component;

rename-prop.png

一度に複数の props の名前を変更する:renameProps

renameProps の複数版。

使用例(props にtext="テスト" num={10}を指定)

import React from "react";
import { renameProps } from 'recompose';

const enhance = renameProps({
  'text': 'renameText',
  'num': 'renameNum'
});

const Component = enhance(({ text, renameText, num, renameNum }) => {
  return (
    <div>
      <p>{text ? text : 'propsなし'}</p>
      <p>{renameText ? renameText : 'propsなし'}</p>
      <p>{num ? num : 'propsなし'}</p>
      <p>{renameNum ? renameNum : 'propsなし'}</p>
    </div>
  );
});

export default Component;

rename-prop.png

平坦化した props を追加する:flattenProp

あくまで平坦化した props を追加なので、平坦化の元になった props もそのまま残ります。

使用例(props にobj={{'a': 'A', 'b': 'B', 'c': 'C'}}を指定)

import React from "react";
import { flattenProp } from 'recompose';

const enhance = flattenProp('obj');

const Component = enhance(({ obj, a, b, c }) => {
  return (
    <div>
      <p>{obj.a}{obj.b}{obj.c}</p>
      <p>{a}</p>
      <p>{b}</p>
      <p>{c}</p>
    </div>
  );
});

export default Component;

flatten-prop.png

state を追加する:withState

第1引数に state 名、第2引数に state を更新する関数、第3引数にデフォルト値を指定します。
state を更新する関数を使用する際の引数は、ただ設定値だけを渡すほかに、現在の値を引数とした処理の記述も可能です。
デフォルト値の指定に関しても、単純な値のほかにコールバック関数も指定できます。

使用例(基本的な使い方の例と同じです)

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

const enhance = withState('counter', 'setCounter', 0);

const Component = enhance(({ counter, setCounter }) => {
  return (
    <div>
      <p>カウンター: {counter}</p>
      <button onClick={() => setCounter(n => n + 1)}>Increment</button>
      <button onClick={() => setCounter(n => n - 1)}>Decrement</button>
    </div>
  );
});

export default Component;

state-counter.gif

関数ハンドラーを追加する:withHandlers

定義した関数ハンドラーには props が渡されるので、その値を使った処理を記述できます。

使用例(複数の HOC を併用するやり方の例と同じです)

import React from 'react';
import { compose, withState, withHandlers } from 'recompose';

const enhance = compose(
  withState('counter', 'setCounter', 0),
  withHandlers({
    incrementCounter: props => () => {
      props.setCounter(v => v + 1)
    },
    decrementCounter: props => () => {
      props.setCounter(v => v - 1)
    }
  })
)

const ComposeComponent = enhance(
  ({
    counter,
    incrementCounter,
    decrementCounter
  }) => {
    return (
      <div>
        <p>カウンター:{counter}</p>
        <button onClick={incrementCounter}>Increment</button>
        <button onClick={decrementCounter}>Decrement</button>
      </div>
    )
})

export default ComposeComponent;

※プレビューは withState の例と同じなので省略

state と関数ハンドラーを追加する:withStateHandlers

state と、その state に関する関数ハンドラーをまとめて定義したい時は、こちらを使用。

使用例(props に指定なし)

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

const enhance = withStateHandlers(
  ({ initialCounter = 0 }) => ({
    counter: initialCounter,
  }),
  {
    incrementOn: props => () => ({
      counter: props.counter + 1,
    }),
    decrementOn: props => () => ({
      counter: props.counter - 1,
    }),
    resetCounter: (_, { initialCounter = 0 }) => () => ({
      counter: initialCounter,
    }),
  }
)

const ComposeComponent = enhance(
  ({ counter, incrementOn, decrementOn, resetCounter }) => {
    return (
      <div>
        <p>カウンター:{counter}</p>
        <button onClick={incrementOn}>Increment</button>
        <button onClick={decrementOn}>Decrement</button>
        <button onClick={resetCounter}>Reset</button>
      </div>
    )
})

export default ComposeComponent;

state-handlers-counter.gif

ローカル Reducer を追加する:withReducer

Action を発行して、そのタイプに応じた状態更新をする Redux ライクな処理を書くことができます。
Redux を使うまでではないが、より複雑な状態管理をしたいという時に向いています。

import React from 'react';
import { compose, withReducer, withHandlers } from 'recompose';

const counterReducer = (count, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return count + 1
    case 'DECREMENT':
      return count - 1
    default:
      return count
  }
}

const enhance = compose(
  withReducer('counter', 'dispatch', counterReducer, 0),
  withHandlers({
    incrementOn: props => () => props.dispatch({type: 'INCREMENT'}),
    decrementOn: props => () => props.dispatch({type: 'DECREMENT'}),
  })
);

const ComposeComponent = enhance(
  ({ counter, incrementOn, decrementOn }) => {
    return (
      <div>
        <p>カウンター:{counter}</p>
        <button onClick={incrementOn}>Increment</button>
        <button onClick={decrementOn}>Decrement</button>
      </div>
    );
});

export default ComposeComponent;

※プレビューは withState の例と同じなので省略

ライフサイクルを追加する:lifecycle

componentDidMountをはじめとした、ライフサイクルを追加できます。

使用例

import React from 'react';
import { compose, withState, lifecycle } from 'recompose';

const enhance = compose(
  withState('text', 'setText', ''),
  lifecycle({
    componentDidMount() {
      this.props.setText('initial');
    }
  })
)

const ComposeComponent = enhance(({ text }) => {
    return (
      <div>
        <p>{text}</p>
      </div>
    )
})

export default ComposeComponent;

lifecycle.png


Recompose は業務で使用した経験があったので、記事に起こすのそんなに難しくないと思いきや、思いのほか機能が多かったです(苦笑)
自分が使った機能はほんの一部にすぎなかったようです。

またもや長くなって力尽きたので、一部機能のみ紹介にしました。
気が向いたら他の機能も書くかも?

今後使われなくなっていくと思われるライブラリではありますが、保守案件とかで触れる機会がある可能性はあるので、さらっとした知識は一応持っておきたいですね。

参考リンクまとめ

株式会社ゆめみ

Discussion