React TableにみられるHeadless UIなライブラリ

最近 react-table を使う機会があったので README を読んでいると、React Table is a "headless" UI library という面白そうなコンセプトが書かれていました。

今回は react-table にみられる Headless UI についての内容になります。

React Table

react-table は、ソートやフィルターなど機能が搭載された Table コンポーネントを提供してくれる React の UI ライブラリです。

現時点で最新バージョンである v7 から hook に対応した書き方へと大幅な変更が行われました。そして今回の主題である Headless UI は v7 以降に採用されています。

Why React Table is a "headless" UI library

UI ライブラリが Headless になることは、どんなメリットがあるのでしょうか。react-table の doc によると、その答えは「Headless User Interface Components」に書かれているとのことなので、読んでみます。


あなたは「Heads」「Tails」がそれぞれ 50% の確率で現れるコイントスを実装する必要があるとする。マネージャーに「これは何年もの研究が必要だ!」と言って早速仕事に取り掛かる。

const CoinFlip = () =>
  Math.random() < 0.5 ? <div>Heads</div> : <div>Tails</div>;

コイントスの実装は思ったより簡単だったため、誇らしげにすぐ成果を共通した。

「素晴らしい! ついでにクールな画像も表示できますか?」「もちろんだ!」

const CoinFlip = () =>
  Math.random() < 0.5 ? (
    <div>
      <img src="/heads.svg" alt="Heads" />
    </div>
  ) : (
    <div>
      <img src="/tails.svg" alt="Tails" />
    </div>
  );

「SEO のために『Heads』と『Tails』のラベルも欲しいです」「オーケー(マーケティングサイトにでも使うのか?)」

const CoinFlip = (
  // We'll default to false to avoid breaking the applications
  // current usage.
  { showLabels = false }
) =>
  Math.random() < 0.5 ? (
    <div>
      <img src="/heads.svg" alt="Heads" />

      {/* Add these labels for the marketing site. */}
      {showLabels && <span>Heads</span>}
    </div>
  ) : (
    <div>
      <img src="/tails.svg" alt="Tails" />

      {/* Add these labels for the marketing site. */}
      {showLabels && <span>Tails</span>}
    </div>
  );

その後すぐに新たな要件が出現する。

「クリックすると確率が変わるボタンを追加できますか?」「………」

const flip = () => ({
  flipResults: Math.random()
});

class CoinFlip extends React.Component {
  static defaultProps = {
    showLabels: false,
    // We don't repurpose `showLabels`, we aren't animals, after all.
    showButton: false
  };

  state = flip();

  handleClick = () => {
    this.setState(flip);
  };

  render() {
    return (
      // Use fragments so people take me seriously.
      <>
        {this.state.showButton && (
          <button onClick={this.handleClick}>Reflip</button>
        )}
        {this.state.flipResults < 0.5 ? (
          <div>
            <img src="/heads.svg" alt="Heads" />
            {showLabels && <span>Heads</span>}
          </div>
        ) : (
          <div>
            <img src="/tails.svg" alt="Tails" />
            {showLabels && <span>Tails</span>}
          </div>
        )}
      </>
    );
  }
}

最後に同僚から「確率を調整できるサイコロ機能をもった <DiceRoll /> を作りたいんだけど、君の <CoinFlip /> を再利用できない?」と。

あなたには 2 つの選択肢がある。1 つは崩壊しないように <DiceRoll /> の機能を取り込むこと。もう 1 つは「申し訳ありませんが、共有できるものはここにはございません」と返事すること。

Enter Headless Components

Headless な UI コンポーネントは view からロジックと動作を分離します。このパターンは、コンポーネントのロジックが十分に複雑で、view から切り離されている場合に非常に効果的です。

<CoinFlip/> を Headless で実装すると次のようになります。

const flip = () => ({
  flipResults: Math.random()
});

class CoinFlip extends React.Component {
  state = flip();

  handleClick = () => {
    this.setState(flip);
  };

  render() {
    return this.props.children({
      rerun: this.handleClick,
      isHeads: this.state.flipResults < 0.5
    });
  }
}

このコンポーネントは何もレンダリングしません。つまり headless です。

具体的なアプリケーションのコードは次のようになります。

<CoinFlip>
  {({ rerun, isHeads }) => (
    <>
      <button onClick={rerun}>Reflip</button>
      {isHeads ? (
        <div>
          <img src="/heads.svg" alt="Heads" />
        </div>
      ) : (
        <div>
          <img src="/tails.svg" alt="Tails" />
        </div>
      )}
    </>
  )}
</CoinFlip>

マーケティングサイトではこう:

<CoinFlip>
  {({ isHeads }) => (
    <>
      {isHeads ? (
        <div>
          <img src="/heads.svg" alt="Heads" />
          <span>Heads</span>
        </div>
      ) : (
        <div>
          <img src="/tails.svg" alt="Tails" />
          <span>Tails</span>
        </div>
      )}
    </>
  )}
</CoinFlip>

プレゼンテーションからロジックを完全に分離できました! これにより、視覚的な柔軟性が大幅に向上します。

> でも、これって単なるレンダリングのいち手法じゃない?

そのとおり。この headless コンポーネントはレンダープロップとして実装されています。しかし状況に応じて HOC として実装することもできます。

ポイントは、コインを反転させる「メカニズム」と、そのメカニズムへの「インターフェース」を分離させることです。

What about <DiceRoll />?

Headless コンポーネントのすばらしい点は、コンポーネントを一般化して同僚の新しい <DiceRoll /> 機能をサポートすることが非常に簡単になるということです。

const run = () => ({
  random: Math.random()
});

class Probability extends React.Component {
  state = run();

  handleClick = () => {
    this.setState(run);
  };

  render() {
    return this.props.children({
      rerun: this.handleClick,

      // By taking in a threshold property we can support
      // different odds!
      result: this.state.random < this.props.threshold
    });
  }
}

この headless コンポーネントを使用すると、コンシューマーに変更を加えることなく <CoinFlip /> の実装を変更できます。

const CoinFlip = ({ children }) => (
  <Probability threshold={0.5}>
    {({ rerun, result }) =>
      children({
        isHeads: result,
        rerun
      })
    }
  </Probability>
);

これにて無事に同僚は <RollDice /> を実装することができます。

const RollDice = ({ children }) => (
  // Six Sided Dice
  <Probability threshold={1 / 6}>
    {({ rerun, result }) => (
      <div>
        {/* She was able to use a different event! */}
        <span onMouseOver={rerun}>Roll the dice!</span>
        {/* Totally different interface! */}
        {result ? (
          <div>Big winner!</div>
        ) : (
          <div>You win some, you lose most.</div>
        )}
      </div>
    )}
  </Probability>
);

きれいでしょ?


Headless UI がもたらす3つのメリット

「Headless User Interface Components」の内容を理解したところで、react-table が Headless UI を採用するメリットは 3 つあると書かれています。

  1. ロジックと動作の分離
  2. メンテナンス
  3. 拡張性

1 と 3 は <CoinFlip /> の例で確認したとおりです。react-table を使うユーザーは見た目に専念でき、また拡張性も非常に高いものになることが容易に想像できます。また React Hook に対応した書き方のおかげで、よりシンプルでわかりやすい実装になっています。

2 はメンテナー側の視点です。ロジックの API だけを管理すればいいため、更新・メンテナンスが簡潔になります。結果的に利用者もライブラリが使いやすくなるので、双方ともに win-win ですね。

まとめ

Headless UI そのものは数年前からすでに存在していたみたいです。実際に「Headless User Interface Components」の筆者は downshift から影響を受けたと言っていました。

副作用などの独自の処理を hook に閉じ込めて作る React のカスタムフックですが、うまく使うことで headless UI なコンポーネントも作りやすそうです。

参考