🐧

Reactで具体的なレンダリングを伴わないジェネリックコンポーネントをつくるということ

2023/06/07に公開

つまりそれってどういうことぉ?

例えばページネーションとかでしょうか。

ブログの記事一覧のようなUI……だったり、そういうアレです。フロントエンドエンジニアなら一度は実装したことがあるはずです。
プリミティブな要件を満たすだけならベタ書きで実装自体は簡単です。

じゃあ別プロダクトでも使いまわしたい!あるいは別のページでは別のデザインをもったページネーターにしたい!!となったときにどうしましょうか?
一番簡単な選択肢はclassNameを注入することでしょう。でもそれってなんだかイケてない気がする……

プログラミング上でこういう高レベルな再利用性が欲しいときに皆さんならどうしますか?
そう、Genericsを使いますよね。

つまり、Genericsを使います。
やりたいのは、現在のページに応じてリストから特定の要素を抜き出し、外部から指定されたDOMをレンダリングすることです。

/** 組み込みで存在しない操作なのでヘルパー */
export function* chunk<T>(array: ReadonlyArray<T>, chunkSize: number) {
  let counter = 0;
  let end = counter + chunkSize;
  while (counter < array.length) {
    yield array.slice(counter, end);
    counter = counter + chunkSize;
    end = counter + chunkSize;
  }
}


type Props<T> = {
  loading?: boolean;
  items: ReadonlyArray<T>;
  countPerPage: number;
  selectedPage: number;
  componentIfEmpty: ReactNode;
  /** keyが必要 */
  itemRender: (item: T) => ReactNode;
  loadingComponent: ReactNode;
};

export default function PaginatorPane<T>(props: Props<T>) {
  const {loading, items, countPerPage, selectedPage, itemRender, componentIfEmpty, loadingComponent} = props;
  const chunkedItems = useMemo(() => Array.from(chunk(items, countPerPage)), [items, countPerPage]);
  const loadingDummy = useMemo(
    () => Array.from([...Array(countPerPage).keys()].map((i) => <React.Fragment key={i}>{loadingComponent}</React.Fragment>)),
    [countPerPage, loadingComponent],
  );

  if (loading) {
    return <>{loadingDummy}</>;
  }

  if (items.length === 0) {
    return <>{componentIfEmpty}</>;
  }

  const renderSet = chunkedItems[selectedPage];
  if (renderSet == null) {
    return <>{loadingDummy}</>;
  }

  return <>{renderSet.map((t) => itemRender(t))}</>;
}

はいできました。

items: ReadonlyArray<T> に対して itemRender: (item: T) => ReactNode; を割り当てることで、具体的な要素のレンダリングは外部から注入できます。
また、一番外側を React.Fragment とすることで完全に具体DOMフリーな実装となりました。
これでwrapperにはflexだろうがgridだろうが好きな要素を使ってもいいし、使わなくてもよくなります。divがひとつ挟まるだけでCSSの制御が難しくなることだってあるんだ。

const renderSet = chunkedItems[selectedPage]; でrenderSetがnullになることを想定している部分に疑問を抱く方がいるかもしれませんが、画面の初回レンダリングで最低限のアイテムだけをロードしてページングした後など任意のタイミングで遅延ロードさせることを想定しているわけですね。
こういうとき、大抵のAPIはtotal countを返してくれるはずです。

そういえば↑のコンポーネントにはページ送りをするためのボタンがありませんね?
筆者はcontroller部分のcomponentを分けるべきだと考えています。paneとcontrollerの間に何かを挟みたいデザインになることだってあるでしょう、きっと。

具体的な実装はどうなるでしょう?筆者が想像するに、Controllerが要求するpropsはこんな感じです:

type PageControllerProps = {
  loading?: boolean;
  totalCount: number;
  currentItemLength: number;
  selectedIndex: number;
  countPerPage: number;
  onSelected: (index: number) => void;
  onLoadNext: (index: number) => void;
};

currentItemLengthを使って、 [→] のボタンを押した時に呼ばれるコールバックをonSelectedとonLoadNextとに振り分けることで長大な要素をレンダリングするようなコントローラーにも対応できるはずでしょう。
楽しそうですね。

このようにGenericsの考え方を使うことで実際のDOM処理を行わないcomponentを作れると遊びの幅が広がるんじゃないかとおもいます。
例えばtoastとか、そういうコンポーネントもgenerics componentパターン(いま勝手に命名しました)で作れそうですよね?

↓ は同じ発想で以前に作ったDOMを間引くためのメタコンポーネントです。無限スクロールとかを実装させられたときに同じようなものを作ったフロントエンドエンジニアは多いんじゃないでしょうか

https://github.com/hachibeeDI/react-list-throttle-component

最近作ったと探してきたらおもったより古くて、泣きました。

この方式で汎用コンポーネントを作れば依存するCSSフレームワークの流行り廃りに振り回されることもなくなりますね。
まぁできあいのコンポーネントを使いたい人って自分でCSS書きたくない人だからデフォでスタイルがあたってないコンポーネントって使ってもらえないんですけどね。

今回は以上です。

Discussion