LoginSignup
3
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-04-29

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


※2020/10/03追記
この記事は Zenn に転載しました。
以降の更新は Zenn の方で行っていきますので、最新状態はそちらでご確認ください。


※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との併用(※2020/05/06追記)

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(※2020/05/06追記)

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引数の名称にリネーム。
このHOC1つにつき、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-props.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(※2020/05/06追記)

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

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

2020/05/06 一部追記しました。

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

参考リンクまとめ

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2