バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

ヘッダー画像

Render Propsのコールバック地獄を解消するユーティリティーを公開した

はじめに

こちらは バイセルテクノロジーズAdvent Calendar 2022の25日目の記事です。

昨日は小林さんによる「実務で使った React-Hook-Form × Zod の事例紹介」という記事でした。

こんにちは! テクノロジー戦略本部 開発二部の金子です。

自分が担当するプロジェクトでは、横断的関心事を扱うためにRender Propsを頻繁に利用しています。

本記事では、Render Propsのコールバック地獄を解消するユーティリティーを公開したので、紹介します。

github.com

対象読者

以下のような課題を抱えている開発者の参考になれば幸いです。

  • Render Propsのコールバック地獄で可読性が下がっている
  • react-adopt を検討したがTypeScriptが不完全で断念した

Render Props とは?

Reactの公式ドキュメントより引用すると、以下のように書かれています。

“Render Props (render prop)”という用語は、値が関数である props を使って、コンポーネント間でコードを共有するためのテクニックを指します。

reactjs.org

Render Propsは、横断的関心事に利用されます。 Render Propsを用いた実装例は以下の通りです。

// こちらからコードを引用: https://dmitripavlutin.com/solve-react-render-props-callback-hell/

import Mouse from "Mouse";

function ShowMousePosition() {
  return (
    <Mouse
      render={({ x, y }) => (
        <div>
          Position: {x}px, {y}px
        </div>
      )}
    />
  );
}

上記の例では、マウスの位置を追跡した結果を子コンポーネントに渡しています。 Render Propsを利用することで、位置情報取得のコンポーネントと、位置情報を受け取って表示するコンポーネントを切り分けることができます。

Render Props の課題

Render Propsは横断的関心事を扱うための優れた手段です。 しかし、複数のRender Propsを利用すると問題が発生します。 Render Propsはコールバック関数を利用するため、複数扱うとネストが深くなっていきます。

複数のRender Propsを用いた例は以下の通りです。

// こちらからコードを引用: https://dmitripavlutin.com/solve-react-render-props-callback-hell/

function DetectCity() {
  return (
    <AsyncCoords
      render={({ lat, long }) => {
        return (
          <AsyncCity
            lat={lat}
            long={long}
            render={({ city }) => {
              if (city === null) {
                return <div>Unable to detect city.</div>;
              }
              return <div>You might be in {city}.</div>;
            }}
          />
        );
      }}
    />
  );
}
// Somewhere
<DetectCity />;

上記のように、AsyncCoords のコールバック関数が AsyncCity に渡り、AsyncCity のコールバック関数が続きます。

このような問題に対して、以下の記事で react-adopt を用いた解決策が述べられています。

dmitripavlutin.com

従来手法 (react-adopt)

react-adopt は、Render Propsのコールバック関数を解消するユーティリティーを提供します。

github.com

先ほどのコードを react-adopt を用いて書き換えると、以下のようになります。

// こちらからコードを引用: https://dmitripavlutin.com/solve-react-render-props-callback-hell/

import { adopt } from "react-adopt";

const Composed = adopt({
  coords: ({ render }) => <AsyncCoords render={render} />,
  city: ({ coords: { lat, long }, render }) => (
    <AsyncCity lat={lat} long={long} render={render} />
  ),
});
function DetectCity() {
  return (
    <Composed>
      {({ city }) => {
        if (city === null) {
          return <div>Unable to detect city.</div>;
        }
        return <div>You might be in {city}.</div>;
      }}
    </Composed>
  );
}
// Somewhere
<DetectCity />;

react-adopt を用いると、Render Propsのコールバック地獄を解消できます。 しかし、react-adopt の利用を検討したところ、様々な課題がありました。

従来手法の課題

react-adopt の課題として以下が挙げられます。

  1. TypeScriptサポートが不完全
  2. メンテナンスが止まっている (最終リリースは2018年5月)

TypeScriptサポートの問題点について、以下の2つを例として挙げます。

github.com

github.com

#24 については、tsconfigの allowSyntheticDefaultImportstrue にしないと動作しないことが挙げられています。

#21 については、adopt の引数に渡すコンポーネントの children がオプショナル型でなければ動作しないことが挙げられています。

Property 'children' is missing in type but required in type

上記より、react-adopt を使うためにユーザ側での設定や実装を捻じ曲げなければならないという問題があります。

提案手法

従来手法の問題点を解消するため、弊社では独自のユーティリティーを自作しました。

github.com

コードを抜粋すると、以下のようなインタフェースになっています。 「Render Propsを持つコンポーネント」の数に応じたシグネチャを定義しています。 これにより、複雑な型の定義を可能にしています。

/**
 * Composes multiple render prop components
 */
export function compose<
  P extends Record<string, unknown>,
  A extends Record<string, unknown>
>(
  CompA: React.FC<P & { render: React.FC<A> }>
): React.FC<Omit<P, "render"> & { render: React.FC<A> }>;

export function compose<
  P extends Record<string, unknown>,
  A extends Record<string, unknown>,
  B extends Record<string, unknown>
>(
  CompA: React.FC<P & { render: React.FC<A> }>,
  CompB: React.FC<A & { render: React.FC<B> }>
): React.FC<Omit<P, "render"> & { render: React.FC<A & B> }>;

実際の使用例は以下の通りです。

import { compose } from '@buysell-technologies/react-compose';

const Composed = compose(
  AsyncCoords
  AsyncCity
);

function DetectCity() {
  return (
    <Composed render={({ city }) => {
      if (city === null) {
        return <div>Unable to detect city.</div>;
      }
      return <div>You might be in {city}.</div>;
    }} />
  );
}

// Somewhere
<DetectCity />

compose に AsyncCoordsAsyncCity を渡すことで、合成されたコンポーネント (Composed) が作成されます。合成されたコンポーネント (Composed) は、渡されたコンポーネントより型推論された型が付与されるようになります。

また、react-adoptと比較して、引数のマッピングが必要ないためより簡単に利用できます。

比較

比較結果は以下の通りです。

従来手法 提案手法
TypeScript サポート
使いやすさ
多機能

提案手法では、型の問題が解消され、プロパティのマッピングを行わずに使うことができるようになりました。これにより、Render Propsのコールバック地獄を型安全かつ簡単に解決できるようになりました。

しかし、従来手法に対して機能の豊富さという観点では劣っており、今後の機能追加で対応していこうと考えています。 具体的には、従来手法では render 以外の props を利用できるのに対し、提案手法では render という名前を使わなければならないというルールがあります。

まとめ

Render Propsのコールバック地獄を解消するユーティリティーを作りました。 react-adopt と比較して、Render Propsのコールバック地獄を型安全かつ簡単に解決できるようになりました。

このパッケージが同様の悩みを抱えている方の助けになればと思っております。

BuySell Technologiesではエンジニアを募集しています。こういった取組に興味がある方はぜひご応募をお願いします!

hrmos.co

参考

ja.reactjs.org

dmitripavlutin.com

github.com