【React】カッコよく書けるタブコンポーネントを自作する

2022/04/10に公開

はじめに

ウェブアプリを書いていてよく出てくるUIの一つに「タブ」があると思います。

UIライブラリを導入していれば、大抵のライブラリにはその機能を実現出来るコンポーネントが実装されていることからも、よく求められる機能の一つといえるでしょう。

そして、数あるUIライブラリのうちの一つ Ant Design にも、以下のように書ける便利なタブコンポーネントが含まれています。

リファレンス

ところでこのコードの「<Tabs> の下に <TabPane> を置くとそれがタブUIとして表示される」というライブラリデザイン、どうやって実装しているのか気になりませんか?

<Tabs defaultActiveKey="1" onChange={callback}>
  <TabPane tab="Tab 1" key="1">
    Content of Tab Pane 1
  </TabPane>
  <TabPane tab="Tab 2" key="2">
    Content of Tab Pane 2
  </TabPane>
  <TabPane tab="Tab 3" key="3">
    Content of Tab Pane 3
  </TabPane>
</Tabs>

実は最近まで「こういうのってどうやるといいのかなぁ」とは思いつつも、これを実現しているライブラリの中身を見に行くでもなくなんとなくモヤモヤした日々を送っていたところ、似たような処理を自前で実装する必要が出てきてしまい、頑張って考えた結果なんとか実現できたので、この記事にはその成果を書いていこうと思います。

結論: Context を使う

Context API を使い、親で生成した state を Context を経由して子に渡し、子は渡された setState 等を用いて自分の状態を親に伝えます。

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

import {
  createContext,
  memo,
  PropsWithChildren,
  useCallback,
  useContext,
  useLayoutEffect,
  useMemo,
  useState,
  VFC,
} from "react";

import "./tab.css";

type TabState = {
  activeKey: string;
  addItem: (title: string, key: string) => void;
};

type TabValue = {
  title: string;
  key: string;
};

const TabContext = createContext<TabState>({
  activeKey: "",
  addItem: () => {},
});

type TabProps = {
  defaultKey: string;
};

export const Tab: VFC<PropsWithChildren<TabProps>> = ({
  defaultKey,
  children,
}) => {
  const [activeKey, setActiveKey] = useState(defaultKey);
  const [tabs, setTabs] = useState<TabValue[]>([]);
  const addTab = useCallback((title: string, key: string) => {
    setTabs((tabs) => {
      if (tabs.findIndex((item) => item.key === key) >= 0) {
        return tabs;
      } else {
        return [...tabs, { title, key }];
      }
    });
  }, []);

  const state = useMemo<TabState>(
    () => ({
      activeKey,
      addItem: addTab,
    }),
    [activeKey, tabs]
  );

  return (
    <TabContext.Provider value={state}>
      <div className="tab-wrap">
        {tabs.map(({ title, key }) => (
          <div
            key={key}
            className={`tab-item ${activeKey === key ? "active" : ""}`}
            onClick={() => setActiveKey(key)}
          >
            {title}
          </div>
        ))}
      </div>
      {children}
    </TabContext.Provider>
  );
};

type TabItemProps = {
  tabKey: string;
  title: string;
};

export const TabItem: VFC<PropsWithChildren<TabItemProps>> = ({
  title,
  tabKey,
  children,
}) => {
  const { activeKey, addItem } = useContext(TabContext);

  useLayoutEffect(() => {
    addItem(title, tabKey);
  }, []);

  return tabKey === activeKey ? <>{children}</> : null;
};

これはこのように使います。

const Main = () => {
  return <Tab defaultKey="first">
    <TabItem tabKey="first" title="first">
      first content
    </TabItem>
    <TabItem tabKey="second" title="second">
      second content
    </TabItem>
  </Tab>
}

実行結果

  • 初期状態

  • second をクリックした状態

出来ているようです、いいですね。

バーっと示してハイ終わりだとアレなので、ポイントを解説していきます。

Context 経由で state 更新用関数を子コンポーネントに渡す

この部分です。

...
const addTab = useCallback((title: string, key: string) => {
  setTabs((tabs) => {
    if (tabs.findIndex((item) => item.key === key) > 0) {
      return tabs;
    } else {
      return [...tabs, { title, key }];
    }
  });
}, []);

const state = useMemo<TabState>(
  () => ({
    activeKey,
    addItem: addTab,
  }),
  [activeKey, tabs]
);
...

基本的に、props から渡されてくる children の中身を見て云々のようなことは出来ないようになっているので、親側で管理するステート(この例の場合はTabの要素)を子の側で更新してもらうためにこのように更新用関数を Context に詰めます。

(非公開APIとか使えば出来るのかもしれないけど少なくとも TypeScript の型が付いている範囲ではそのような操作が出来る関数はない)

ここ、単に useState で返される setter をそのまま渡してもいいのですが、敢えてそうせずに 操作を明確にした関数 を渡すようにしています。こういう親子間のステート共有は複雑になりやすいので、なるべくこういう風に操作を限定するようにしたほうがメンテナンス性が高まると思われます。(あんま変わらんかもしれんのでこれはまあどちらかというと僕の趣味です)

親側で管理している state を子側で使う

useContext している部分ですね。

export const TabItem: VFC<PropsWithChildren<TabItemProps>> = ({
  title,
  tabKey,
  children,
}) => {
  const { activeKey, addItem } = useContext(TabContext);

  useLayoutEffect(() => {
    addItem(title, tabKey);
  }, []);

  return tabKey === activeKey ? <>{children}</> : null;
};

ここでやっているのは、初期化時に自分の title を親に伝えることと、現在選択されているタブが自分に渡されている tabKey と一致していたら children を描画するということです。

ここのキモは useLayoutEffect を使っていることで、 useEffect を使うと子要素の初期化が終わる前に親要素のレンダリングが行われ、一瞬だけ親要素が空の状態が表示されてしまいます。

しかも初期化終了後、要素が描画された状態へと変わるためガクっと見えてしまうことになります。とても格好悪いですし、UXの観点からもよくありません。

それを防ぐために、useLayoutEffect を使って描画前に子要素の初期化を終わらせるようにしています。

useEffectuseLayoutEffect はどちらを使えばいいのか迷いがちですが、初期化が終了した状態だけがユーザーに見えて欲しいような場合は useLayoutEffect の出番、ということになると思います。

まとめ

このような書き方を使えば使う側がすっきり書けるようなコンポーネントというのは、割とあると思います。

例えばテーブル要素なんかは children に列を表すコンポーネントを書いていけばヘッダーに自動的に列の名前が出てくる、みたいな感じで書けるととても直感的になるので、使う側としては非常に便利です。

Context というと深くネストされたコンポーネント間での state の共有という役割に目がいきがちですが、こういったトリッキーな使い方をすることでちょっと不思議なコンポーネントを作ったりもできるので、奥深いなと感じます。

このようなデザインパターンになっているコンポーネントは直観だと割と不思議な感じがしていましたが、実装してみたら意外と単純な作りになっていたのでとても勉強になりました。

ただ、こういうパターンは「propsを見ればなんとなく使い方が分かる」シンプルさを犠牲しており使う側が使い方や依存関係を覚える必要が出てくるため、あまり乱用するとむしろ使いづらくなってしまうかもしれません。

よって、タブやテーブルのような、使う側から見ても依存関係が明確な機能に対して使うのが良いのかなと思います。

おまけ

今回作ったコードは以下に置いてあります。
https://github.com/hapo31/example-nested-depend-component

おまけのおまけ

今回作ったタブでは、以下のように書くと「どのタブでも常に表示されている要素」を実現できます。

<Tab defaultKey="first">
  <h3>common content</h3> // ★
  <TabItem tabKey="first" title="first">
    first content
  </TabItem>
  <TabItem tabKey="second" title="second">
    second content
  </TabItem>
</Tab>

実行結果

  • 初期状態

  • second をクリックした状態

// ★ で示した部分のコンテンツが常に表示されているのが分かると思います。

なぜこうなるかというと、中身の表示/非表示の判断が子要素の中で行われているためです。

大本の Tab に手を加えることなくこういったことも出来るので、応用例はもっとあるように思えてきますね。

Discussion