React.Children を使ってカッコよく書ける自作タブコンポーネントをカッコよく実装する

2023/09/30に公開

以前こういう記事を書きました。

https://zenn.dev/articles/b7db16e9b0b120/

ざっくりいえば React.Context を使ったちょっと無理やりな実装だったわけですが、上記の記事の実装にはバグがあり、ホットリロードをすると無限にタブが増えていくような挙動をすることがあります。
理由としてはタブの更新時に追加済みアイテムのキーを考慮していないからです。
原因が分かっているのであれば修正は可能とはいえ、そもそもこういうふうに「子要素まで一旦描画しないと親要素の状態が確定しない」という設計そのものが間違っているのではないかという考え方もできると思います。

というわけで今回は React.Context を使わないバージョンを作ってみました。

結論

今回は React.Children を存分に活用していきます。
これによって子要素が変化するタイミングでタブ要素が再生成されるので、ホットリロード時に奇妙な挙動をすることもなく、また処理の流れも上から下に向かって流れる自然なものにすることが出来ます。

以下にコードを示します。

import {
  Children,
  PropsWithChildren,
  isValidElement,
  useMemo,
  useState,
} from "react";

type Props = PropsWithChildren<{
  defaultOpenKey?: string;
}>;

export default function Tab({ children, defaultOpenKey }: Props) {
  const [openKey, setOpenKey] = useState(defaultOpenKey);

  const tabs = useMemo(
    () =>
      Children.map(children, (child) =>
        isTabItem(child)
          ? {
              title: child.props.title,
              children: child.props.children,
              tabKey: child.props.tabKey,
            }
          : null
      )?.filter((item) => item != null) ?? [],
    [children]
  );

  return (
    <div>
      <div>
        {tabs.map((element, index) => (
          <a
            onClick={(e) => {
              e.preventDefault();
              setOpenKey(element.tabKey);
            }}
            key={element.tabKey}
            style={{
              backgroundColor:
                element.tabKey === openKey || (openKey == null && index === 0)
                  ? "red"
                  : "inherit",
            }}
          >
            {element.title}
          </a>
        ))}
      </div>
      <div>
        {tabs.find((element) => element.tabKey === openKey)?.children ??
          Children.toArray(children)[0]}
      </div>
    </div>
  );
}

type TabItemProps = PropsWithChildren<{
  tabKey: string;
  title: string;
}>;

export function TabItem({ children }: TabItemProps) {
  return <>{children}</>;
}

function isTabItem(
  child: unknown
): child is React.ReactElement<
  TabItemProps,
  string | React.JSXElementConstructor<unknown>
> {
  return isValidElement(child) && child.type === TabItem;
}

使い方は以下のような感じです。

<Tab>
  <TabItem tabKey="1" title="page1">
    hoge
  </TabItem>
  <TabItem tabKey="2" title="page2">
    fuga
  </TabItem>
  <TabItem tabKey="3" title="page3">
    piyo
  </TabItem>
</Tab>

動作確認

(本当は CodeSandbox を埋め込みたかったんですが、執筆時点でなぜか Share を押すとページにエラーが出て共有URLが発行できませんでした。 Github Pages を立てたのでこちらをご覧ください。)
https://hapo31.github.io/zenn-tab-component-2/

解説

前回と違い、タブの状態は純粋に子要素の存在によって生成されるようになりました。
また、子要素は完全に型を付けているだけのような存在となり、親の状態を書き換えたりすることはなくなりました。
いくつかトリッキーなところがあるので、そこは重点的に解説していきます。

React.Children とは?

https://react.dev/reference/react/Children

Children lets you manipulate and transform the JSX you received as the children prop.

DeepL翻訳: Childrenでは、children propとして受け取ったJSXを操作したり変換したりすることができます。

children として受け取ったコンポーネントに対して様々な操作を行うことが出来る、いわばユーティリティ群です。
ただし、このページの頭に Pitfall (落とし穴) として表示されている通り、これを使ってトリッキーなことをしようとするとコンポーネントが壊れやすくなります。
そのため、使用はなるべく避け、どうしても必要な場合はあくまで「子コンポーネントをラップする」ような使い方に留め、直接子コンポーネントを操作するようなコードは避けるべきでしょう。

今回の用途では Children.map を使って子要素を順番に変換し、以下のように受け取ったコンポーネントの props から titletabKey を抜き出すようなコードになっています。

Children.map(children, (child) =>
  isTabItem(child)
    ? {
        title: child.props.title,
        children: child.props.children,
        tabKey: child.props.tabKey,
      }
  : null
)?.filter((item) => item != null) ?? [],

ところで、前述のルールに従うとこの処理は子要素の中身を参照しているため「壊れやすい」と言えます。
その点については、 isTabItem というヘルパー関数を作り、渡されたコンポーネントが想定されている TabItem かどうかをチェックするようにしています。(ついでに型も付けています)

function isTabItem(
  child: unknown
): child is React.ReactElement<
  TabItemProps,
  string | React.JSXElementConstructor<unknown>
> {
  return isValidElement(child) && child.type === TabItem;
}

ここで isValidElement という関数が出てきましたが、これも React 本体に用意されている関数です。
名前の通り、渡された値が React のコンポーネントとしてふさわしいかどうかをチェックすることが出来ます。
さらに、2つ目の条件である child.type === TabItem によって、この child が真に TabItem であるかどうかをチェックすることができます。
「最初から child.type === TabItem でいいんじゃないの?」という指摘があると思いますが、まあ言ってしまえばこれは TypeScript 的な都合で型が付かないからです。
この辺は好みの分かれるところですが、今回は紹介的な意味も込めて使ってみました。(個人的にも用意されているものは使いたいですしね)

また、 Children には単純に配列に変換する Children.toArray という関数もあります。
そのため、 Children.map(children, ...)Children.toArray(children).map(...) のラッパーと言えるでしょう。
(こちらもこのあと使っています。)

タブ情報の生成

というわけでようやく実装の詳細ですが、以下のコードによって作るべきタブの情報を生成しているということになります。
被る部分もありますが、再度引用します。

  const tabs = useMemo(
    () =>
      Children.map(children, (child) =>
        isTabItem(child)
          ? {
              title: child.props.title,
              children: child.props.children,
              tabKey: child.props.tabKey,
            }
          : null
      )?.filter((item) => item != null) ?? [],
    [children]
  );

ここでは title からタブ部分に表示される文字列、 children からアクティブ時に表示される要素、tabKey からタブを特定するためのキーの3つを取得しています。
isTabItem によって child の型を確定させているため、この時点で props には型が付いています。
また useMemo することで、タブが切り替わっただけのときにこの処理が再計算されることを防いでいます。

表示

ここまで来たら、あとは単純にタブの情報から実際のタブを生成する処理を書くだけです。

  return (
    <div>
      <div>
        {tabs.map((element, index) => (
          <a
            onClick={(e) => {
              e.preventDefault();
              setOpenKey(element.tabKey);
            }}
            key={element.tabKey}
            style={{
              backgroundColor:
                element.tabKey === openKey || (openKey == null && index === 0)
                  ? "red"
                  : "inherit",
            }}
          >
            {element.title}
          </a>
        ))}
      </div>
      <div>
        {tabs.find((element) => element.tabKey === openKey)?.children ??
          Children.toArray(children)[0]}
      </div>
    </div>
  );

openKey を配列の要素の tabKey と一致しているかをチェックし、一致していればスタイルを適用しています。(完全に手抜きです)
また、スタイルを適用する際に (openKey == null && index === 0) という2つ目の条件が書かれていますが、これは「 defaultKey が設定されていなかったら0番目をアクティブなものとする」 というのを簡単に書きたかったからです。
以下のようなコードで openKey の初期値をまじめに設定することもできると思います。

  const [openKey, setOpenKey] = useState(() => {
    if (defaultOpenKey) {
      return defaultOpenKey;
    }
    const firstChild = Children.toArray(children)[0];

    if (isTabItem(firstChild)) {
      return firstChild.props.tabKey;
    } else {
      return undefined;
    }
  });

が、まあ単純に (openKey == null && index === 0) でも効果はあまり変わらないので、今回はこれでもいいかなとしています。
また、これはこの後の選択されているタブの要素を表示する処理との兼ね合いもあります。
該当部分のみ再度引用します。

      <div>
        {tabs.find((element) => element.tabKey === openKey)?.children ??
          Children.toArray(children)[0]}
      </div>

tabs から開くべきキーと一致するアイテムを find して children を表示するだけのコードです。
ここで ?? Children.toArray(children)[0] という nullish operator の右側が出てきていますが、ここがまさに「 defaultKey が設定されていなかったら0番目をアクティブなものとする」 との兼ね合いの部分です。
まあ簡単を重視したに過ぎないので、もしこういったコンポーネントを自作してプロダクションで用いる場合は、もうちょっとハンドリング処理をきっちり書いてもよいでしょう。
ただし、 undefined は React のコンポーネントとしては「何も表示しない」要素としての効果を持つため、例え find や Children.toArray(children)[0]undefined であったとしてもこのコードは valid です。

まとめ

React.Children はユーザーコードではあまり使うことはないユーティリティ群ではあると思いますが、この記事のタブコンポーネントのように「子要素の数によって動的に処理を切り替える」ような用途ではとても便利に扱うことが出来る一方、React 公式が言っているように、チェックを怠ると壊れやすいコードになる可能性もあるため、注意して扱う必要があるでしょう。
また、前回の記事と比較して今回の実装は直接子要素を親要素から扱うことでタブ部分の生成処理をすべて親コンポーネント内で閉じることが出来ており、コードの見通しも改善したと思います。

今回作ったものは github でも見ることが出来ます。

https://github.com/hapo31/zenn-tab-component-2

余談

そもそも Children を使う必要は・・・?

さきほども書きましたが、本来 Children は使わずとも似たような機能を持つコンポーネントは書けるはずです。

https://react.dev/reference/react/Children#alternatives

例えば下記でも具体的に触れられていますが、タブの情報をただの配列として最初から渡してしまうという方法です。

https://react.dev/reference/react/Children#accepting-an-array-of-objects-as-a-prop

※ 以下のコードは上記リンク内のコードを本記事の趣旨に合うように改変したものです。

export default function App() {
  return (
    <Tab tabs={[
      { title: 'first', tabKey: '1', content: <p>1つめ</p> },
      { title: 'second', tabKey: '2', content: <p>2つめ</p> },
      { title: 'third', tabKey: '3', content: <p>3つめ</p> }
    ]} />
  );
}

確かにこの書き方のほうがただの値なのでより直感的ですし、なにより個別に子要素を import せずとも tabs の型情報を使って補完させながら書くこともできます。
Ant Design の Tabs コンポーネントも、執筆時点での最新版(5.9.4)では、このように配列として渡すスタイルを推奨しているようです。

https://ant.design/components/tabs

まあ、この記事の趣旨は半分ぐらい「カッコよく書ける」ということなので、この場ではどちらがより実利に即しているかという議論は置いておこうと思います。

Discussion