【React】汎用コンポーネントの実装テクニックまとめ

JavaScript

本記事で紹介する汎用コンポーネントとは、ボタンをはじめとした複数の箇所から呼び出されるUIパーツの基本となるコンポーネントのことを指します。

使い回しのしやすい形で汎用コンポーネントを実装することで、必要となる汎用コンポーネントの数を削減できます。
今回は使い回しやすい汎用コンポーネントを実装するために覚えておくとよいテクニックについて紹介します。

検証環境はnext 12.1.4react 18.0.0です。

PropsでコンポーネントのCSSを直接指定する方法

PropsからコンポーネントのCSSを指定できるようにすることで、1つの汎用コンポーネントから複数の派生コンポーネントを生成できるため、CSSのパターンごとにコンポーネントを用意する必要がなくなります。

ここでは汎用ボタンコンポーネントのwidthheightfont-sizeをPropsで指定する例について紹介します。

方法1: タグのstyle属性に直接指定する(標準環境での実装方法)

Propsから渡された値をタグのstyle属性に直接記述する方法です。JSX内でstyleを指定するので見栄えがよくないですがCSSライブラリに依存しないのでデフォルトの環境で実装できるというメリットがあります。

実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import styles from "./Button.module.css";

type Props = {
  label: string;
  onClick: () => void;
  width: number;
  height: number;
  fontSize: number;
};

export const Button: FC<Props> = ({
  label,
  onClick,
  width,
  height,
  fontSize,
}) => {
  return (
    <button
      className={styles.button}
      onClick={onClick}
      style={{
        width: `${width}px`,
        height: `${height}px`,
        fontSize: `${fontSize}px`,
      }}
    >
      {label}
    </button>
  );
};

なお、styleで指定するプロパティが多い場合は変数に設定を外だしするとコードが読みやすくなります。

(略)

export const Button: FC<Props> = ({
  label,
  onClick,
  width,
  height,
  fontSize,
}) => {
+  const buttonStyle = {
+    width: `${width}px`,
+    height: `${height}px`,
+    fontSize: `${fontSize}px`,
+  };
  return (
    <button
      className={styles.button}
      onClick={onClick}
+     style={buttonStyle}
-     style={{
-       width: `${width}px`,
-       height: `${height}px`,
-       fontSize: `${fontSize}px`,
-     }}
    >
      {label}
    </button>
  );
};

src/components/shared/Button.module.css

.button {
  min-width: 100px; /* labelの文字列がボタンからはみ出さないように横幅を確保するため、min-widthで横幅を定義している */
  padding: 0 25px;
  color: white;
  font-weight: bold;
  background: darkblue;
  border-radius: 10px;
  cursor: "pointer";
}

コンポーネントの呼び出し例は以下の通りです。

src/components/Example.tsx

import { FC } from "react";
import { Button } from "./shared/Button";

const handleClick = () => {
  alert("Hi");
};

export const Example: FC = () => {
  return (
    <>
      <Button
        label={"ボタン"}
        onClick={handleClick}
        width={100}
        height={50}
        fontSize={15}
      />
      <br />
      <Button
        label={"ボタン"}
        onClick={handleClick}
        width={200}
        height={100}
        fontSize={30}
      />
    </>
  );
};

方法2: Emotionを利用する

Emotionとは比較的最近できたCSS in JSライブラリです。後発ということもあり多機能で使い勝手がよいと評判です。
Emotionの導入方法の詳細はEmotionをNext.js x TypeScriptの環境にインストールする手順で紹介しています。

Emotionを利用した実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import { css } from "@emotion/react";

type Props = {
  label: string;
  onClick: () => void;
  width: number;
  height: number;
  fontSize: number;
};

export const Button: FC<Props> = ({
  label,
  onClick,
  width,
  height,
  fontSize,
}) => {
  return (
    <button css={css(buttonStyle(width, height, fontSize))} onClick={onClick}>
      {label}
    </button>
  );
};

const buttonStyle = (width: number, height: number, fontSize: number) => {
  return css({
    minWidth: "100px",
    width: `${width}px`,
    height: `${height}px`,
    fontSize: `${fontSize}px`,
    background: "darkblue",
    padding: "0 25px",
    color: 'white',
    fontWeight: "bold",
    borderRadius: "10px",
    cursor: "pointer",
  });
};

コンポーネントの呼び出し例は「方法1」と同様のため省略します。

Propsでコンポーネントのパターン(タイプ)を制御する方法

汎用コンポーネントに派生パターンを持たせ、Propsでパターンの指定をできるようにする方法です。

ここでは汎用ボタンコンポーネントのサイズのパターン(大、小)をPropsで制御できるようにする例について紹介します。

方法1: Propsの値によって適用クラスを変更する(標準環境での実装方法)

Propsの値をもとにclassNameに適用するクラス名を変更することでコンポーネントのパターンを切り替える方法です。

実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import styles from "./Button.module.css";

type Props = {
  label: string;
  onClick: () => void;
  size: 'small' | 'large';
};

export const Button: FC<Props> = ({ label, onClick, size }) => {
  return (
    <button
      // sizeが'small'の時: styles.buttonとstyles.smallが適用される
      // sizeが'small'ではない時: styles.buttonとstyles.largeが適用される
      className={`${styles.button} ${
        size === 'small' ? styles.small : styles.large
      }`}
      onClick={onClick}
    >
      {label}
    </button>
  );
};

src/components/shared/Button.module.css

.button {
  padding: 0 25px;
  color: white;
  font-weight: bold;
  background: darkblue;
  border-radius: 10px;
  cursor: "pointer";
}

.small {
  font-size: 15px;
  height: 50px;
  min-width: 100px;
}

.large {
  font-size: 25px;
  height: 75px;
  min-width: 150px;
}

コンポーネントの呼び出し例は以下の通りです。

src/components/Example.tsx

import { FC } from "react";
import { Button } from "./shared/Button";

const handleClick = () => {
  alert("Hi");
};

export const Example: FC = () => {
  return (
    <>
      <Button label={"ボタン"} onClick={handleClick} size="small" />
      <br />
      <Button label={"ボタン"} onClick={handleClick} size="large" />
    </>
  );
};

方法2: classnamesを使う

classnamesは条件に応じて適用させるクラス名を簡単に変更できるようするライブラリです。

classnamesのインストール方法は以下の通りです。

$ yarn add classnames
$ yarn add --dev @types/classnames

classnamesを利用した実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import cx from "classnames";
import styles from "./Button.module.css";

type Props = {
  label: string;
  onClick: () => void;
  size: "small" | "large";
};

export const Button: FC<Props> = ({ label, onClick, size = "small" }) => {
  return (
    <button
      // sizeが'small'の時: styles.buttonとstyles['small']が適用される
      // sizeが'large'の時: styles.buttonとstyles['large']が適用される
      className={cx(styles.button, styles[size])}
      onClick={onClick}
    >
      {label}
    </button>
  );
};

なお、classnamesを利用したクラス名の指定方法はドット形式でも配列形式でも同じ意味を持つので1styles.buttonstyles['button']に書き換え可能です。

CSSおよびコンポーネントの呼び出し例は「方法1」と同様のため省略します。

方法3: Emotinon使う

Emotionを利用した実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import { css } from "@emotion/react";

type Props = {
  label: string;
  onClick: () => void;
  size: "small" | "large";
};

export const Button: FC<Props> = ({ label, onClick, size }) => {
  return (
    <button css={css(buttonStyle(size))} onClick={onClick}>
      {label}
    </button>
  );
};

const buttonStyle = (size: "small" | "large") => {
  return css({
    minWidth: size === "small" ? "100px" : "150px",
    height: size === "small" ? "50px" : "75px",
    fontSize: size === "small" ? "15px" : "25px",
    background: "darkblue",
    padding: "0 25px",
    color: "white",
    fontWeight: "bold",
    borderRadius: "10px",
    cursor: "pointer",
  });
};

コンポーネントの呼び出し例は「方法1」と同様のため省略します。

補足: コンポーネントのパターンをUnion型で表現する方法について

パターンのUnion型を定義することで未定義のパターンがPropsから渡ってくることを防げます。
TypeScriptではtypeof X[kyeof typeof X]を利用することでUnion型が定義できます。

typeof X[kyeof typeof X]の意味についてはTypeScriptの『typeof X[keyof typeof X]』の意味を順を追って理解するで紹介しています。

コンポーネントのパターンをUnion型で表現すると実装は以下のようになります。

src/components/shared/Button.tsx

import React, { FC } from "react";
import { css } from "@emotion/react";

export const Size = {
  Small: "small",
  Large: "large",
} as const;
// 以下のように推論される
// type const = {
//   readonly Small: "small";
//   readonly Large: "large";
// }


type Size = typeof Size[keyof typeof Size];
// 以下のように推論される
// type Size = "small" | "large"

type Props = {
  label: string;
  onClick: () => void;
  size: Size;
};

export const Button: FC<Props> = ({ label, onClick, size }) => {
  return (
    <button css={css(buttonStyle(size))} onClick={onClick}>
      {label}
    </button>
  );
};

const buttonStyle = (size: Size) => {
  return css({
    minWidth: size === Size.Small ? "100px" : "150px",
    height: size === Size.Small ? "50px" : "75px",
    fontSize: size === Size.Small ? "15px" : "25px",
    background: "darkblue",
    padding: "0 25px",
    color: "white",
    fontWeight: "bold",
    borderRadius: "10px",
    cursor: "pointer",
  });
};

コンポーネントの呼び出し例は以下の通りです。

src/components/Example.tsx

import { FC } from "react";
import { Button, Size } from "./shared/Button";

const handleClick = () => {
  alert("Hi");
};

export const Example: FC = () => {
  return (
    <>
      <Button label={"ボタン"} onClick={handleClick} size={Size.Small} />
      <br />
      <Button label={"ボタン"} onClick={handleClick} size={Size.Large} />
    </>
  );
};

Propsの真偽値を利用してコンポーネントの内容を制御する方法

真偽値をPropsで受け取ることで「アクティブ/非アクティブ」や「表示/非表示」など、2つの状態をコンポーネントで表現できます。
2つの状態をトグル形式で切り替えたいケースはよくあるため、真偽値の受け渡し方法は汎用コンポーネントを作成する上で覚えておくと良いテクニックです。

ここでは汎用ボタンコンポーネントのボタンの「活性/非活性」をPropsで制御できるようにする例について紹介します。

方法1: Propsの値によって適用クラスを変更する(標準環境での実装方法)

真偽値をもとにclassNameに適用するクラス名を変更することでコンポーネントの状態を切り替える方法です。
真偽値が条件になるため三項演算子を利用した実装になります。

実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import styles from "./Button.module.css";

type Props = {
  label: string;
  onClick: () => void;
  disabled: boolean;
};

export const Button: FC<Props> = ({ label, onClick, disabled }) => {
  return (
    <button
      className={`${styles.button} ${
        disabled ? styles.disable : styles.active
      }`}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
};

src/components/shared/Button.module.css

.button {
  font-size: 15px;
  height: 50px;
  min-width: 100px;
  padding: 0 25px;
  color: white;
  font-weight: bold;
  border-radius: 10px;
}

.active {
  background: darkblue;
  cursor: "pointer";
}

.disable {
  background: gray;
}

コンポーネントの呼び出し例は以下の通りです。

src/components/Example.tsx

import { FC } from "react";
import { Button } from "./shared/Button";

const handleClick = () => {
  alert("Hi");
};

export const Example: FC = () => {
  return (
    <>
      <Button label={"ボタン"} onClick={handleClick} disabled={false} />
      <br />
      <Button label={"ボタン"} onClick={handleClick} disabled={true} />
    </>
  );
};

方法2: classnamesを利用する

classnamesを利用した実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import cx from "classnames";
import styles from "./Button.module.css";

type Props = {
  label: string;
  onClick: () => void;
  disabled: boolean;
};

export const Button: FC<Props> = ({ label, onClick, disabled }) => {
  return (
    <button
      className={cx(styles.button, disabled ? styles.disable : styles.active)}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
};

CSSおよびコンポーネントの呼び出し例は「方法1」と同様のため省略します。

方法3: Emotionを利用する

Emotionを利用した実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import { css } from "@emotion/react";

type Props = {
  label: string;
  onClick: () => void;
  disabled: boolean;
};

export const Button: FC<Props> = ({ label, onClick, disabled }) => {
  return (
    <button
      css={css(buttonStyle(disabled))}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
};

const buttonStyle = (disabled: boolean) => {
  return css({
    minWidth: "100px",
    height: "50px",
    fontSize: "15px",
    background: disabled ? "gray" : "darkblue",
    padding: "0 25px",
    color: "white",
    fontWeight: "bold",
    borderRadius: "10px",
    cursor: "pointer",
  });
};

コンポーネントの呼び出し例は「方法1」と同様のため省略します。

補足: Propsでtrueを渡す方法について

Propsでtrueを渡す場合={true}を省略できます。
つまり、例えば以下のコードは同義となります。

// 両方ともdisabledはtrueになる
<Component disabled={true} />
<Component disabled />

ただし、公式ドキュメントの「JSX In Depth#Props Default to “True”」に書かれているように、={true}を省略するとES6のショートハンドと混同させてしまうため省略しないほうが無難です。

Propsのデフォルト値を用意する方法

デフォルト引数を利用することでPropsのデフォルト値が用意できます。
たとえば汎用ボタンコンポーネントのdisabledのデフォルト値をfalseにする実装例は以下の通りです。

src/components/shared/Button.tsx

import React, { FC } from "react";
import { css } from "@emotion/react";

type Props = {
  label: string;
  onClick: () => void;
  disabled?: boolean; // デフォルト値が用意されているのでオプション引数(optional prameter)でよい
};

export const Button: FC<Props> = ({ label, onClick, disabled = false }) => {
  return (
    <button
      css={css(buttonStyle(disabled))}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
};

const buttonStyle = (disabled: boolean) => {
  return css({
    minWidth: "100px",
    height: "50px",
    fontSize: "15px",
    background: disabled ? "gray" : "darkblue",
    padding: "0 25px",
    color: "white",
    fontWeight: "bold",
    borderRadius: "10px",
    cursor: "pointer",
  });
};

コンポーネントの呼び出し例は以下の通りです。

src/components/Example.tsx

import { FC } from "react";
import { Button } from "./shared/Button";

const handleClick = () => {
  alert("Hi");
};

export const Example: FC = () => {
  return (
    <>
      <Button label={"ボタン"} onClick={handleClick} disabled={false} />
      <br />
      <Button label={"ボタン"} onClick={handleClick} disabled={true} />
      <br />
      {/* disabledを指定しない場合、disabledはデフォルト値のfalseとなる */}
      <Button label={"ボタン"} onClick={handleClick} />
    </>
  );
};

参考: 今回紹介したテクニックを利用した汎用ボタンコンポーネント

参考として、今回のテクニックを利用して作成した汎用ボタンコンポーネントのサンプルコードを紹介します。仕様は以下の通りです。

  • ボタンサイズは小と大の2パターンがある
  • ボタンの色は青、黄、赤の3パターンがある
  • withArrowの真偽値で矢印アイコンの表示/非表示を制御する
  • disabledの真偽値でボタンの活性/非活性を制御する
  • disabledがtrueの場合、ボタンは灰色になる
  • ボタンのデフォルトのデザインは「サイズ小、青色、矢印アイコンなし」

なお、矢印アイコンの表示/非表示の制御には&&演算子(論理積演算子、論理結合)を利用しています。
&&演算子による表示制御の詳細解説は【React】JSXにおけるコンポーネントの表示・非表示の制御方法まとめで紹介しています。

src/components/shared/Button.tsx

import React, { FC } from "react";
import { css } from "@emotion/react";

export const Size = {
  Small: "small",
  Large: "large",
} as const;

export const ButtonType = {
  Primary: "darkblue",
  Warning: "darkorange",
  Danger: "darkred",
} as const;

type Size = typeof Size[keyof typeof Size];

type ButtonType = typeof ButtonType[keyof typeof ButtonType];

type Props = {
  label: string;
  onClick: () => void;
  size?: Size;
  buttonType?: ButtonType;
  disabled?: boolean;
  withArrow?: boolean;
};

export const Button: FC<Props> = ({
  label,
  onClick,
  size = Size.Small,
  buttonType = ButtonType.Primary,
  disabled = false,
  withArrow = false,
}) => {
  return (
    <button
      css={css(buttonStyle(size, buttonType, disabled))}
      disabled={disabled}
      onClick={onClick}
    >
      {label}
      {withArrow && (
        <div css={css(arrowStyle)}>
          <span css={css(arrowIconStyle)} />
        </div>
      )}
    </button>
  );
};

const buttonStyle = (size: Size, buttonType: ButtonType, disabled: boolean) => {
  return css({
    minWidth: size === Size.Small ? "100px" : "150px",
    height: size === Size.Small ? "50px" : "75px",
    fontSize: size === Size.Small ? "15px" : "25px",
    background: disabled ? "gray" : buttonType,
    padding: "0 25px",
    color: "white",
    fontWeight: "bold",
    borderRadius: "10px",
    cursor: disabled ? undefined : "pointer",
    position: "relative",
  });
};

const arrowStyle = css({
  position: "absolute",
  right: "10px",
  top: "50%",
  transform: "translateY(calc(-50% - 1px))",
});

const arrowIconStyle = css({
  width: "9px",
  height: "9px",
  transform: "rotate(45deg)",
  display: "inline-block",
  borderTop: "2px solid white",
  borderRight: "2px solid white",
});

コンポーネントの呼び出し例は以下の通りです。

src/components/Example.tsx

import { FC } from "react";
import { Button, Size, ButtonType } from "./shared/Button";
const handleClick = () => {
  alert("Hi");
};

export const Example: FC = () => {
  return (
    <>
      <Button label={"ボタン"} onClick={handleClick} />
      <br />
      <Button label={"ボタン"} onClick={handleClick} withArrow={true} />
      <br />
      <Button label={"ボタン"} onClick={handleClick} disabled={true} />
      <br />
      <Button
        label={"ボタン"}
        onClick={handleClick}
        size={Size.Large}
        buttonType={ButtonType.Danger}
      />
    </>
  );
};

表示結果は以下の通りです。

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!