Render Props パターンでロジックと UI を分離する

profile image

React の Render Props パターンとはどのようなもので、いつ、どのように使えばよいのでしょうか? Hooks を使う場合との違いは何でしょうか? 例を交えて解説します。

この記事は Jetbrains's Coding Agent Junie junie logoによって翻訳されました。誤訳があれば教えてください!

React には、HOC や Custom Hook など、コンポーネントとデータ、ロジックを再利用するための様々な手法があります。今回は、その中から render props パターンについて紹介します。

Render Props パターンとは?

非常に簡単に言うと、Render Props はコンポーネントの prop として JSX を返す関数を渡し、何をレン더リングするかを親(外部)に委譲するパターンです。簡単な例を見ると次のようになります。(ちなみに、props の名前が必ずしも render である必要はありません。)

typescript
// (1) 枠組みを提供するコンポーネント
const Box = ({ render }) => {
  return (
    <div style={{ border: '2px solid blue', padding: '20px', borderRadius: '8px' }}>
      {render()}
    </div>
  );
};

// (2) 利用側: 「テキストを入れたい」
<Box render={() => <span>シンプルなテキストです。</span>} />

// (3) 利用側: 「画像を入れたい」
<Box render={() => <img src="logo.png" alt="ロゴ" />} />

このように、JSX を返す関数を props として受け取り、それを呼び出すパターンです。非常にシンプルなので、これだけではこのパターンがなぜ有用なのか、どのように使えるのかがまだ分かりにくいかもしれません。

データを渡す Render Props

もう少し発展させてみましょう。例えば、子コンポーネントで特定のデータを管理しているが、親によってそのデータの見せ方を変えたい場合があります。

typescript
// (1) データを保持しているコンポーネント
const UserProvider = ({ render }) => {
  const user = { name: "Sanghyeon", role: "Developer" };

  // 保持しているデータを render 関数に渡して実行
  return <div>{render(user)}</div>;
};

// (2) 利用側 A: 名前を大きく表示したい場合
<UserProvider render={(user) => <h1>{user.name}</h1>} />

// (3) 利用側 B: 役割と名前を一緒に表示したい場合
<UserProvider render={(user) => <p>{user.name} ({user.role})</p>} />

このように、コンポーネントが持つデータを外部(親)で自由に取り扱いながらレンダリングできます。

children prop の活用

上の例では render という名前の prop を使用しましたが、children prop を活用するのがより一般的です。この方法を使うと、一般的なコンポーネントのように子要素を囲む形になり、可読性が向上します。

typescript
// (1) render prop の代わりに children を関数として実行
const UserProvider = ({ children }) => {
  const user = { name: "Sanghyeon", role: "Developer" };

  return <div>{children(user)}</div>;
};

// (2) 利用側: より直感的な構造になります。
<UserProvider>
  {(user) => (
    <div>
      <h1>{user.name}</h1>
      <p>{user.role}</p>
    </div>
  )}
</UserProvider>

Hooks による代替

しかし、現在のコードも Hook で十分に代替可能であり、単にデータを扱うだけであれば Hook を使用する方がよりクリーンでシンプルになる場合があります。

typescript
// Hook に書き換えたコード
const useUser = () => {
  const user = { name: "Sanghyeon", role: "Developer" };
  return user;
};

// (1) 利用側 A: 名前を大きく
function LargeName() {
  const user = useUser();
  return <h1>{user.name}</h1>;
}

// (2) 利用側 B: 役割と名前
function NameAndRole() {
  const user = useUser();
  return <p>{user.name} ({user.role})</p>;
}

最近の React 開発では、多くの Render Props パターンが Hook に置き換えられました。しかし、Hook では解決が難しい領域があります。それは、**「ロジックに特定の DOM 構造が結合される必要がある場合」**です。

実践的な活用例

無限スクロールを実装してみましょう。無限スクロールを実装する方法はいくつかありますが、ここでは IntersectionObserver とスクロールの終わりを検知するダミーコンポーネントを利用する方法で実装するとします。

typescript
function PostListContainer() {
  const { items, fetchNextPage, hasMore, isLoading } = useInfiniteScrollHook();
  const observerRef = useRef(null); // 開発者が直接管理

  return (
    <div>
      <PostList items={items} />

      // (1) 不便な点: データ(isLoading)に応じた UI 処理をここで毎回手動で行う必要がある
      {isLoading && <MyCustomSpinner />}

      // (2) 不便な点: ロジックのための「ダミー div」を忘れずに手動で入れる必要がある
      {hasMore && <div ref={observerRef} style={{ height: '20px' }} />}
    </div>
  );
}

Hook はロジックのみを提供するため、ref と DOM の接続は開発者が手動で行う必要があります。このようなロジックが繰り返される場合、かなり面倒な作業になるでしょう。

Render Props 方式への修正

typescript
// (1) Render Props コンポーネント
function InfiniteScroll({ onLoadMore, hasMore, children }) {
  const [isLoading, setIsLoading] = useState(false);
  const observerRef = useRef(null);

  useEffect(() => {
    if (!hasMore || isLoading) return;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setIsLoading(true);
        onLoadMore().finally(() => setIsLoading(false));
      }
    });

    if (observerRef.current) observer.observe(observerRef.current);
    return () => observer.disconnect();
  }, [hasMore, onLoadMore, isLoading]);

  return (
    <div>
      // 子関数に現在のローディング状態を渡す
      {children(isLoading)}

      // 必須のダミー要素はコンポーネントが責任を持つ
      {hasMore && <div ref={observerRef} style={{ height: '20px' }} />}
    </div>
  );
}

// (2) 利用側: 内部状態である isLoading を受け取って UI を決定する
<InfiniteScroll hasMore={hasMore} onLoadMore={fetchNextPage}>
  {(isLoading) => (
    <>
      <PostList items={items} />
      // ローディングバーをリストのすぐ下に置くか、右上に小さく表示するかを親が決定する
      {isLoading && <MyCustomSpinner />}
    </>
  )}
</InfiniteScroll>

Render Props パターンを利用すると、ロジックと必須の DOM 構造を一式として提供できます。利用側は observerRef を気にする必要がなくなり、isLoading を children prop として渡すことで、外部からどのように表示するかをコントロールできるようになります。

メリットとデメリット

メリット

  • ロジックと構造の完全なカプセル化: InfiniteScroll の例のように、ロジックを動かすために特定の DOM 要素(ダミー div など)が必ず必要な場合、それをコンポーネント内部に隠蔽して再利用性を最大化できます。
  • 制御権の委譲: データはコンポーネントが管理しますが、「どのように見せるか」は利用する親が 100% 決定します。そのおかげで、非常に柔軟な UI 構成が可能になります。

デメリット

  • Render Prop Hell (ネストの問題): 複数の Render Props を入れ子にして使用する場合、コードの階層が深くなり、可読性が急激に低下する可能性があります。
  • Hook に比べて冗長なコード: 単純なロジック共有であれば、Hook を使用するよりも記述量が増え、構造が複雑に見えることがあります。

まとめ

単にデータやロジックを再利用する目的であれば、Hook で十分です。しかし、ロジックと DOM 構造までカプセル化したい場合は、Render Props パターンを検討してみてください。

それでもまだ使い道がピンとこない場合は、Render Props パターンを活用したライブラリ集があるので、そこからインスピレーションを得てみましょう!


参照