現場で使えるReactコンポーネント第3弾 Tab編
はじめに
本記事は、現場で使えるReactコンポーネント第3弾の記事です。
第1弾のButton編の記事はこちら
第2弾のInputText編の記事はこちら
今回は、実装しようとすると少し悩む人も多いと思われる、Tabコンポーネントです。
学生時代の私は自前で実装することをサボって、ライブラリに頼ってました
お仕事の場になると、基本的なコンポーネントは自前で作成することが多いです。
Tabコンポーネント作るの面倒だと思った時に、この記事を参考にしていただければ幸いです。
Tab.tsx
今回、作成するTabコンポーネントの見た目
開発環境は以下の通りです。
node.js v18.14.2
react v18.2.0
typescript v4.9.5
実際に作成したコードはこちらです。
import {
createContext,
FC,
ReactNode,
useMemo,
useState,
useContext,
ReactElement,
} from "react";
import classNames from "classnames";
import styles from "./Tab.module.css";
type TabKey = string | number;
// -1- TabLabelの型をstringだけでなくnumberとJSX.Elementも許容する
type TabLabel = string | number | JSX.Element;
type TabProps = {
defaultKey: TabKey;
// -2- childrenの型を指定
children: ReactElement<TabItemProps> | ReactElement<TabItemProps>[];
className?: string;
};
type TabHeader = {
tabKey: TabKey;
label: TabLabel;
};
type TabContextState = {
activeKey: TabKey;
};
// -3- Contextを用いて親子間で値を共有する
const TabContext = createContext<TabContextState>({
activeKey: "",
});
export const Tab: FC<TabProps> = ({ defaultKey, children, className }) => {
const [activeKey, setActiveKey] = useState(defaultKey);
// -4- useMemoを使ってTabのヘッダーの要素をメモ化
const headers = useMemo<TabHeader[]>(() => {
const headerArray: TabHeader[] = [];
if (Array.isArray(children)) {
children.forEach((c) => {
// -2- childrenの型を指定
if (c.type !== TabItem) throw Error("TabItemを利用してください");
headerArray.push({
tabKey: c.props.tabKey,
label: c.props.label,
});
});
} else if (children.type === TabItem) {
headerArray.push({
tabKey: children.props.tabKey,
label: children.props.label,
});
} else {
// -2- childrenの型を指定
throw Error("TabItemを利用してください");
}
return headerArray;
}, [children]);
return (
// -3- Contextを用いて親子間で値を共有する
<TabContext.Provider value={{ activeKey }}>
<ul className={classNames(styles.header, className)}>
{headers.map(({ tabKey, label }) => {
return (
<li className={styles.column} key={tabKey}>
<button
className={classNames(
styles.button,
tabKey === activeKey && styles.active
)}
onClick={() => setActiveKey(tabKey)}
>
{label}
</button>
</li>
);
})}
</ul>
{children}
</TabContext.Provider>
);
};
type TabItemProps = {
tabKey: TabKey;
label: TabLabel;
children: ReactNode;
className?: string;
};
export const TabItem: FC<TabItemProps> = ({ tabKey, children, className }) => {
// -3- Contextを用いて親子間で値を共有する
const { activeKey } = useContext(TabContext);
return activeKey === tabKey ? (
<div className={classNames(styles.tabBody, className)}>{children}</div>
) : null;
};
コードだけ見てもわかりづらいので、使い方を示します。
export const Sample = () => {
return (
<Tab defaultKey="item1">
<TabItem tabKey="item1" label="アイテム1">
アイテム1のタブ
</TabItem>
<TabItem tabKey="item2" label="アイテム2">
アイテム2のタブ
</TabItem>
</Tab>
);
};
コードの解説をします。
1. TabLabelの型をstringだけでなくnumberとJSX.Elementも許容する
よくやるパターンは、型をstringだけに設定することです。そうすると、後からアイコン入りのラベルを表示する必要が生まれた場合に困ります。悔しい思いをしなくて済むように、Propsで受け取った値をそのまま表示する場合は型定義を広く設定してあげるようにしましょう。
(「必要になったときにJSX.Elementの型を追加すればいいのでは?」 と思うかもれません。必要になった時に自身で追加するなら問題ないですが、担当者が変わり、他の人が追加する際に解決策に気づかない場合もあるので、想定できることは初めから想定しておいた方が平和です)
2. childrenの型を指定
今回はTabの子要素はTabItemのみに制限しています。
Tabの間違った使い方してしまうことを防ぐためです。
childrenの型をforEachで確認しており、TabItem以外を用いるとエラーが出力されるようになっています。
3. Contextを用いて親子間で値を共有する
今回はTabで選択中のtabKeyを保持する変数activeKeyをコンポーネントツリーへ共有しています。
共有された変数activeKeyを用いてTabItemでactiveKeyとpropsのtabKeyが一致する場合のみ表示しています。
4. useMemoを使ってTabのヘッダーの要素をメモ化
TabのchildrenのpropsのtabKeyとlabelを用いてオブジェクト作成し、作成したオブジェクトの配列を返却しています。
依存配列にchildrenを指定することで、childrenが更新されるたびに再計算が行われます。
これにより、TabItemを動的に追加する際にlabelの順番に乱れは起きず、TabItemを記述した順番通りに表示されます。

最後に
ここまで読んでいただきありがとうございます。
今回の記事で作成したコードはGithubにて公開しています。
デプロイ済みですので、以下のURLからStorybookでの動作確認ができます。
(GithubPagesを使ってデプロイするように変更しました)
第4弾も自前では作りたくない系のコンポーネントを作成予定です。


Discussion