RecomposeでシンプルにReactのHigher Order Componentsを記述する

2018年10月25日に発表があり recompose の開発者である acdlite氏がReactチームに加入することになり、今後はReact本体の機能として Hooks API がこれまで recompose が担ってきた役割を負うことになり、recomposeの代わりにHooks APIを使ってくださいとのアナウンスが公式に出ています。この記事は残しておきますが、今後は積極的にHooks APIを使っていきましょう。

Hi! I created Recompose about three years ago. About a year after that, I joined the React team. Today, we announced a proposal for Hooks. Hooks solves all the problems I attempted to address with Recompose three years ago, and more on top of that.

Hooks APIについての解説は当サイトの下記リンクでも行っています。

もくじ

🙂Recomposeとは?

RecomposeReactのFunctional ComponentとHoC (Higher Order Component)のためのユーティリティライブラリーです。 HoCを使ってかんたんにベースとなるReactコンポーネントを拡張できるヘルパー関数を沢山持っています。

Functional Component

良く知られている通り、ReactにはcreateClass()やES6のクラスでコンポーネントを記述する方法ももありますが 最近では下記のような簡潔なFunctional Componentで記述する書き方が多く見られます

/* Functional Component */
const FunctionalComponent = () => (
    <div>I am a Stateless Functional Component</div>
)

Higher Order Component

Higher Order Componentとは、あるReactコンポーネントをラッピングして、新しいReactコンポーネントを返すReactのコンポーネントのことです。このラッピングのプロセスで、ベースとなるReactコンポーネントに新しいPropsを追加したりなどの拡張がよく行われます。

const function hoc(WrappedComponent) {
  return class EnhancedComponent extends React.Component {
    render() {
      return <WrappedComponent {...this.props}/>
    }
  }
}

const Component = hoc(BaseComponent)
const composedHoc = BaseComponent => hoc1(hoc2(hoc3(BaseComponent)))

詳細は http://postd.cc/react-higher-order-components-in-depth/ を参照してください。

🤔Recomposeを使うメリットは?

Stateとして数字を持つカウンターコンポーネントをHoCを使って実装してみましょう。ES6のクラスを使って実装します。

/* recomposeを使わずにコンポーネントにstateとpropsを付与 */
import React from 'react';

const addCounterState = (BaseComponent) =>
    class extends React.Component<{}, {counter: number}> {

        constructor(props) {
            super(props);
            this.state = {
                counter: 5
            };
        }

        increment = () => {
            this.setState({ counter: this.state.counter + 1})
        }

        decrement = () => {
            this.setState({ counter: this.state.counter - 1 })
        }

        reset = () => {
            this.setState({ counter: 5 })
        }

        render() {
            return <BaseComponent {...this.props } decrement={this.decrement} increment={this.increment} reset={this.reset} counter={this.state.counter} />;
        }
    };

const SampleCounterBaseComponent = (props) => (
    <div>
        <h1>{props.counter}</h1>
        <button onClick={() => { props.increment() }}>increment</button>
        <button onClick={() => { props.decrement() }}>decrement</button>
        <button onClick={() => { props.reset() }}>reset</button>
    </div>
);

const Enhanced = addCounterState(SampleCounterBaseComponent);
export default () => (<Enhanced />)

少し極端な例かもしれませんが、結構コードが長く、読みとくのにもちょっと時間がかかると思います。 Recomposeを使って同じものを実装しましょう。

/* recomposeを使ってComponentにstateとpropsを付与 */

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

/* ベースとなるコンポーネント */
const SampleCounterBaseComponent = (props) => (
    <div>
        <h1>{props.counter}</h1>
        <button onClick={() => { props.increment() }}>increment</button>
        <button onClick={() => { props.decrement() }}>decrement</button>
        <button onClick={() => { props.reset() }}>reset</button>
    </div>
);

/* ベースとなるコンポーネントに効果を付与する関数 */
const counterEnhancer = compose(
    withState('counter', 'updateCounter', 5),
    withHandlers({
        increment: ({ updateCounter }) => () => updateCounter(counter => counter + 1),
        decrement: ({ updateCounter }) => () =>  updateCounter(counter => counter - 1),
        reset: ({ updateCounter }) => () => updateCounter(5)
    })
);

/* ベースコンポーネントにStateやPropsなどを付与 */
const EnhancedComponent = counterEnhancer(SampleCounterBaseComponent);

/* Reactコンポーネントとしてエクスポート */
export default () => (<EnhancedComponent />);

フルスクラッチのHoCで実装するよりも、だいぶコードの行数が少なくなり、簡潔に読みやすく書けるようになったと思います。 このようにRecomposeを使えば、HoCパターンでよりReactコンポーネントを簡潔に読みやすく書けるようになります。例えば、Reduxなどをメインのステートマネージャーで使っている場合、コンポーネント内部で閉じていて、アプリ全体で管理する必要がないステートなどをReduxに格納せずに、こうしてRecomposeなどでコンポーネントに持たせるという使い方も良いかなと思います。

🍞WithState() Recompose経由でsetState

どんなヘルパー関数が用意されているか、ちょっと具体例を見ていきましょう。 例えば、上記の例でも用いられているwithState()を使えば、コンポーネントに対して、シンプルな記述法でStateを追加することができます。

下の例を見てください。

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

const counterEnhancer = withState('counter', 'updateCounter', 5)

const Counter = counterEnhancer(({ counter, updateCounter }) =>
    <div>
        Count: {counter}
        <button onClick={() => updateCounter(counter + 1)}>Increment</button>
        <button onClick={() => updateCounter(counter - 1)}>Decrement</button>
    </div>
)

export default () => (<Counter />);

上記では、下記の効果をComponentに付与する関数 counterEnhancer を作って、Counterコンポーネントに適用しています。Recomposeで付与したStateやメソッドはPropsとして渡ってきます。

  • counterという名前のStateを対象コンポーネントに追加する。初期値は5。
  • updateCounterという名前でStateを更新するための関数を定義する。

🐎lifecycle() Recompose経由でライフサイクルのフックをセット

lifecycle()メソッドを使うことでReactコンポーネントにライフサイクルのフック (componentWillReceivePropsやcomponentDidMountなど) を設定することができます。Stateにはthis.propsでアクセスできます。


interface IState {
  username: string;
  currentPassword: string;
}

const Enhancer = compose(
  withStateHandlers(
    ({
      username = 'hello',
      currentPassword = 'password',
    }: IState) => ({
      username,
      currentPassword,
    }),
    {
      updateState: (state: IState) => (value: any) => {
        return { ...state, ...value };
      },
    },
  ),
  lifecycle({
    componentDidMount() {
      console.log(this.props.username)
      console.log(this.props.currentPassword)
    },
  }),
);

🍳pure() レンダリングのパフォーマンスをチューニング

pure()onlyUpdateForKeys()のような関数を使えば、普通にshouldComponentUpdate()をオーバーライドするように Reactコンポーネントのパフォーマンスをチューニングできます。

下記ではholidayの値がインクリメントされた時にはReactコンポーネントを再レンダリングしますが bugの値がインクリメントされた時にはReactコンポーネントは再レンダリングされません。

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

/* bugと holidayをStateをして付与する。 holidayが更新された時のみ再レンダリングさせる。*/
const enhance = compose(
    withState('bug', 'updateBug', 0),
    withHandlers({
        incrementBug: ({ updateBug }) => () => updateBug(b => b + 1),
    }),
    withState('holiday', 'updateHoliday', 0),
    withHandlers({
        incrementHoliday: ({ updateHoliday }) => () => updateHoliday(h => h + 1),
    }),
    onlyUpdateForKeys(['holiday'])
);

const BaseComponent = (props) => {
    return (
    <article>
        <p>{props.bug}</p> <button onClick={() => { props.incrementBug(1)}}>Increment Bug</button>
        <p>{props.holiday}</p> <button onClick={() => { props.incrementHoliday(1)}}>Increment Holiday</button>
    </article>
    )
};

export default enhance(BaseComponent)

🍏compose() エンハンサーをまとめ上げる


const BaseComponent = props => {...}

/* こんな風に書いても動きます。ちょっと冗長かもしれません。 */
let EnhancedComponent = pure(BaseComponent)
EnhancedComponent = mapProps(/*...*/)(EnhancedComponent)
EnhancedComponent = withState(/*...*/)(EnhancedComponent)

/* 代わりに下のようにcompose()を使ってまとめて書けます。 */
const enhance = compose(
  withState(/*...*/),
  mapProps(/*...*/),
  pure
)

const ComposedComponent = enhance(BaseComponent)

上記で紹介したRecomposeのヘルパー群関数をcompose()でラップすることによって、付与する効果を一つのエンハンサーとしてまとめ上げることができます。

🍜サンプルダウンロード

今回、上記で紹介した機能のうちいくつかを組み合わせてかんたんなサンプルをつくりました。こちらからダウンロードできます。

サンプル

git cloneして下記コマンドで動作させることができます。

git clone git@github.com:AtaruOhto/ns-react-recompose-sample.git
cd ns-react-recompose-sample/ 
yarn
yarn start

以下にサンプル用のコードを抜粋します。

import * as React from 'react';
import { compose, lifecycle, withProps, withStateHandlers } from 'recompose';

/* RecomposeのwithStateHandlersで用いる型を定義 */
interface IStateWithRecompose {
    hobby: string;
    name: string;
}

/* updateState関数でPayloadとして渡されてくる型を定義 */
interface IStateUpdatePayload {
    name?: string;
    hobby?: string;
}

const Enhancer = compose(
    /* Stateを定義すると同時に、Stateをアップデートする関数も定義する */
    withStateHandlers(
        ({ name = 'unknown', hobby = '' }: IStateWithRecompose) => ({
            hobby,
            name
        }),
        /*  */
        {
            updateState: (state: IStateWithRecompose) => (value:  IStateUpdatePayload) => {
                return { ...state, ...value };
            }
        }
    ),

    /* Stateに関係しない関数であるtellAboutMe()をPropsとして渡すようにする */
    withProps({
        tellAboutMe: (props: IBaseComponentProps) => {
            alert(`Your name is ${props.name} and Your hobby is  ${props.hobby}`);
        }
    }),

/* 
    componentDidMount()などReactのライフサイクルメソッドを定義する 
    recomposeで定義したstateにはthis.props経由でアクセス可能。
*/
    lifecycle({
        componentWillUnmount() {
            const props = this.props as IStateWithRecompose;
            console.log(`Your Name is ${props.name} now`);
        },
        componentDidMount() {
            const props = this.props as IStateWithRecompose;
            console.log(`Your Name is ${props.name} now`);
        }
    })
);

/* 
    コンポーネントのPropsの型 (上記のcompose() によってまとめられたエンハンサーを適用する)
    IStateWithRecomposeの型に加えて、tellAboutMe()とupdateState()という関数をプロパティとして持つように定義する。
*/
interface IBaseComponentProps extends IStateWithRecompose {
    tellAboutMe: (props: IBaseComponentProps) => void;
    updateState: (state: IStateUpdatePayload) => void;
}

/* tslint:disable jsx-no-lambda */
const BaseComponent = (props: IBaseComponentProps) => (
    <div>
        <h1>Tell Me About Yourself With Recompose 😋</h1>
        <div>{props.name}</div>
        <input
            type="text"
            value={props.name}
            onChange={(e) => {
                props.updateState({ name: e.currentTarget.value });
            }}
        />
        <input
            type="text"
            value={props.hobby}
            onChange={(e) => {
                props.updateState({ hobby: e.currentTarget.value });
            }}
        />

        <button
            onClick={() => {
                props.tellAboutMe(props);
            }}
        >
            Tell
        </button>
    </div>
);

export const LifeCycle = Enhancer(BaseComponent as React.StatelessComponent);

実際のサンプル動作がこちらになります。

📖参照

  • このエントリーをはてなブックマークに追加