JavaScript
reactjs

Reactのデザインパターン Compound Components

コンポーネント指向での開発も割と枯れてきて、昨年から海外ではいわゆるデザインパターンに名前がついて紹介されることが多くなってきました。
この記事ではその内の一つ、Compound Componentsを紹介いたします。
またタイトルに「Reactの」とついてますが、実装例がReactなだけでコンポーネント指向であれば他のUIライブラリでも考え方は流用可能です。

完成した実装はここに置きました。
https://codesandbox.io/s/104lvmynj4

参考

Compound Componentsとは

はじめに Compound という言葉ですが直訳だと動詞で 混ぜ合わせる という意味です。実際の実装は混ぜ合わせるというよりは「組み合わせる」という方がイメージに近いです。

例として <select><option> タグを考えてみましょう。これらは単独のタグとしてはなんの役にも立ちませんが、組み合わせて使うと"どれが選択中か"という共通の状態を持つことにより力を発揮します。

Compound Componentsによって実現しようとしていることはこれと似ていて、一つのまとまりを示すラッパーコンポーネント(上記の例でいうとselect)を用意し、そのコンポーネントが保持する状態を暗黙的に子のコンポーネントたちに共有することによって見た目にもスッキリした記述にしつつ、構造の柔軟性を手に入れよう!というのが目的です。

具体例がないとイメージしづらいと思うので「指定されたタブによって中身を出し分けするというUI」を実装してみます。まずはCompound Componentsを使わない実装例を書いて、問題点の解説、それからCompound Componentsでリファクタリング、という風にご紹介します。

Compound Componentsを使わない実装例

早速ですが実装例。

import React, { Component } from 'react';
import './Tabs.css';

const TAB_TYPES = {
  HOME: 'home',
  ABOUT: 'about',
  OTHERS: 'others'
};

const tabData = [
  {
    text: 'Home',
    type: TAB_TYPES.HOME
  },
  {
    text: 'About',
    type: TAB_TYPES.ABOUT
  },
  {
    text: 'Others',
    type: TAB_TYPES.OTHERS
  }
];

const Tabs = ({ currentTabType, tabData, onClick }) => (
  <ul className="tabs">
    {tabData.map(tab => (
      <li
        className={currentTabType === tab.type ? 'active' : ''}
        onClick={() => onClick(tab.type)}
      >
        {tab.text}
      </li>
    ))}
  </ul>
);

class App extends Component {
  state = {
    currentTabType: TAB_TYPES.HOME
  };

  changeTab = tabType => {
    this.setState({ currentTabType: tabType });
  }

  renderContents() {
    if (this.state.currentTabType === TAB_TYPES.HOME) {
      return <div>Homeの時の中身</div>;
    } else if (this.state.currentTabType === TAB_TYPES.ABOUT) {
      return <div>Aboutの時の中身</div>;
    } else if (this.state.currentTabType === TAB_TYPES.OTHERS) {
      return <div>OTHERSの時の中身</div>;
    }
    return null;
  }

  render() {
    return (
      <div>
        <Tabs
          currentTabType={this.state.currentTabType}
          tabData={tabData}
          onClick={this.changeTab}
        />
        <main>{this.renderContents()}</main>
      </div>
    );
  }
}

export default App;

表示はこんな感じ。
compound components2.gif

うむ、あまり綺麗ではないですね。特にrenderContents() がキモい。こういったrenderほにゃららみたいな関数作ってその中で巨大なif-else文を構築してしまうのは割とあるあるなのではないでしょうか。

この書き方の問題点は二つあって、第一に見栄えがとても悪い。render関数外にJSXを書くため、コードを追うのもめんどくさいし一つの関数内に複数のJSX要素がある姿は中々威圧感があります。コードリーディングの負荷はお世辞にも低いとは言えないでしょう。

もう一点は条件分岐でバグを混在させやすくなる、という点です。今回の例ではtabTypeのみで条件分岐しているため、あまり問題にはなりませんが、例えば下記のようなもう少し複雑な条件分岐があるとして

if(loading) {
  // ローディング時の中身
} else {
  if(error) {
    return // エラー時の中身
  }
}

「これ error とかが出た時は loadingfalse に必ずなっているって前提で大丈夫?」と心が不安になり、動かしづらくなります。実際に条件分岐の順番変えたらバグってしまった、ということも発生しうるでしょう。if-else文はロジックがそれを書いた開発者の脳みそにしか存在しない状態を生み出す余地があるため、「この状態の時はこのコンポーネント」と明示できる書き方が望まれます。

Compound Componentsを使った実装例

それでは、そんな書き方のCompound Componentsを使って書き直してみます。
いきなりですが完成像。

import React, { Component } from 'react';
import './Tabs.css';

// この辺はさっきと一緒なので省略します
const TAB_TYPES = {...};
const tabData = [...];
const Tabs = ({ currentTabType, tabData, onClick }) => (...);

class App extends Component {
  static Home = ({ tabType, children }) => tabType === TAB_TYPES.HOME ? children : null;
  static About = ({ tabType, children }) => tabType === TAB_TYPES.ABOUT ? children : null;
  static Others = ({ tabType, children }) => tabType === TAB_TYPES.OTHERS ? children : null;
  static Tabs = ({ tabType, changeTab, ...props }) => (
    <Tabs currentTabType={tabType} tabData={tabData} onClick={changeTab} />
  );

  state = {
    currentTabType: TAB_TYPES.HOME
  };

  changeTab = tabType => {
    this.setState({ currentTabType: tabType });
  };

  render() {
    return React.Children.map(this.props.children, child =>
      React.cloneElement(child, {
        tabType: this.state.currentTabType,
        changeTab: this.changeTab
      })
    );
  }
}

const Usage = () => (
  <App>
    <App.Tabs />
    <App.Home>Homeの時の中身</App.Home>
    <App.About>Aboutの時の中身</App.About>
    <App.Others>Othersの時の中身</App.Others>
  </App>
);

export { Usage as default };

だいぶ見栄えがよくなっていませんか?
実装のポイントを順に解説していきます。

Static プロパティの設定 = Compound Componentの作成

App Class内にStaticなプロパティをいくつか追加しました。例えばHome。

static Home = ({ tabType, children }) => tabType === TAB_TYPES.HOME ? children : null;

このHomeは「tabTypeHOME の時のみ props.children を表示するSFC(stateless functional component)」です。これを <App.Home>hogehoge</App.Home> という風に書くと、HOMEの時のみタグに囲まれた部分を表示させることができます。

childrenをに対してpropsを渡す

そして先ほどのStaticプロパティとして定義したコンポーネントたちにpropsを渡す仕組みも必要です。渡し方としては App の childrenをループして、それぞれに対してcloneしてpropsを渡したものを描画する、というやり方を取っています。

render() {
  return React.Children.map(this.props.children, child =>
    React.cloneElement(child, {
      tabType: this.state.currentTabType,
      changeTab: this.changeTab
    })
  );
}

renderのための関数を作成

箱はできたので、あとは並べるだけです。

const Usage = () => (
  <App>
    <App.Tabs />
    <App.Home>Homeの時の中身</App.Home>
    <App.About>Aboutの時の中身</App.About>
    <App.Others>Othersの時の中身</App.Others>
  </App>
);

このようにif-else文を書くことなく、シンプルに「Homeの時はこれを表示して、Aboutの時はこれを表示して…」という書き方ができます。

また書き方の柔軟性も得ることができます。例えば「Homeの時だけタブは下に表示したいなあ〜」なんて時は以下のように書き換えます。

const Usage = () => (
  <App>
    <App.Home>Homeの時の中身</App.Home>
    <App.Tabs />
    <App.About>Aboutの時の中身</App.About>
    <App.Others>Othersの時の中身</App.Others>
  </App>
);

compound components.gif

実際はリニューアルでもしない限りこんな激しくDOM構造変えることはあまりないですが、if-else文で書いていた時と比べて大分柔軟性が増したことが見て取れると思います。

Context APIで階層もお構い無しにする

さて、便利なCompound Components ですが上記の実装例では一つ落とし穴があります。

例えばHome, About, Othersに対して共通のスタイルを当てたいため、以下のようにdivでくくったとします。

function Usage() {
  return (
    <App>
      <App.Tabs />
      <div className="content">
        <App.Home>Homeの時の中身</App.Home>
        <App.About>Aboutの時の中身</App.About>
        <App.Others>Othersの時の中身</App.Others>
      </div>
    </App>
  );
}

すると…なんということでしょう。消えてしまったではありませんか…!!

スクリーンショット 2018-06-17 15.52.00.png

原因はここです。
js
React.Children.map(this.props.children, child =>

this.props.children は第一階層にいる子要素の配列であり(上記の例で言うと<App.Tabs><div className="content">)、propsはそれらのコンポーネントにしか渡されません。つまり第二階層以下のコンポーネントにはtabTypeなどの状態が渡らず、結果表示されない、という事象が起きているわけです。

それでは階層関係なくpropsを行き届かせるために子要素がない粒度まで再帰的にループしてprops渡すのかと言われると、それはちょっとキモ過ぎるので今回はContext APIを使ってみましょう。

Context APIについて

本筋ではないのですがサクッと紹介を。

Context APIは仕様が長らく定まっておらず dangerous 扱いされていました。ドキュメントには以下のように記載されていました。

If you want your application to be stable, don’t use context. It is an experimental API and it is likely to break in future releases of React.
アプリケーションに安定して動作してほしいのであれば context は使わないようにしましょう。contextは試験的なAPIであり、この先のリリースで破壊的変更が入る可能性が高いです。

ですがReact v16.3 にてAPIの仕様が固まり「もう安全に使っていいよ」というのが勧告されました。
Context APIはざっくり述べると親子関係を関係なしにpropsを渡すことを可能にする手段を提供してくれるAPIです。
詳しくはドキュメントを読んで見ましょう。
React Documentation - Context

それでは話を戻しまして、まずはContextを作成し、return を以下のように書き直します。

render() {
  return <TabContext.Provider value={{
    tabType: this.state.currentTabType,
    changeTab: this.changeTab
  }}>{this.props.children}</TabContext.Provider>
}

あとはStaticに定義してコンポーネントたちをTabContext.Consumerで包んであげます。
完成するとこんな感じ。

import React, { Component } from 'react';
import './Tabs.css';

// この辺はさっきと一緒なので省略します
const TAB_TYPES = {...};
const tabData = [...];
const Tabs = ({ currentTabType, tabData, onClick }) => (...);

const TabContext = React.createContext();

class App extends Component {
  static Home = ({ children }) => (
    <TabContext.Consumer>
      {value => (value.tabType === TAB_TYPES.HOME ? children : null)}
    </TabContext.Consumer>
  );
  static About = ({ children }) => (
    <TabContext.Consumer>
      {value => (value.tabType === TAB_TYPES.ABOUT ? children : null)}
    </TabContext.Consumer>
  );
  static Others = ({ children }) => (
    <TabContext.Consumer>
      {value => (value.tabType === TAB_TYPES.OTHERS ? children : null)}
    </TabContext.Consumer>
  );
  static Tabs = props => (
    <TabContext.Consumer>
      {value => (
        <Tabs
          currentTabType={value.tabType}
          tabData={tabData}
          onClick={value.changeTab}
          {...props}
        />
      )}
    </TabContext.Consumer>
  );
  state = {
    currentTabType: TAB_TYPES.HOME
  };

  changeTab = tabType => {
    this.setState({ currentTabType: tabType });
  };

  render() {
    return (
      <TabContext.Provider
        value={{
          tabType: this.state.currentTabType,
          changeTab: this.changeTab
        }}
      >
        {this.props.children}
      </TabContext.Provider>
    );
  }
}

const Usage = () => {
  return (
    <App>
      <App.Tabs />
      <div className="contents">
        <App.Home>Homeの時の中身</App.Home>
        <App.About>Aboutの時の中身</App.About>
        <App.Others>Othersの時の中身</App.Others>
      </div>
    </App>
  );
}

export { Usage as default };

これで無事動くようになりました。
compound components2.gif

まとめ

Compound Components + Context APIで柔軟性は作れる。