この記事で何を解説するのか

実務でよく使われるボタンコンポーネントのコードを、1行ずつ分解して解説します。このコードには、Reactの重要な概念が詰まっています。

  • 型とは何か
  • propsの拡張とは何か
  • デフォルト値の設定
  • スプレッド構文
  • 条件分岐

読み終わる頃には、このコードがなぜこう書かれているのか、理解できるようになるはずです。

前提知識: 「型」とは何か

コードを読む前に、「型」という概念を理解する必要があります。

型の基本

型とは、データの種類を指定するルールです。

let name: string = "田中";  // 文字列型
let age: number = 25;       // 数値型
let isStudent: boolean = true;  // 真偽値型
  • string: 文字列だけ入る
  • number: 数値だけ入る
  • boolean: trueかfalseだけ入る

なぜ型が必要なのか

型があると、間違いを防げるからです。

let age: number = 25;
age = "二十五才";  // エラー! 数値以外は入れられない

TypeScriptは、コードを書いている時点でエラーを教えてくれます。

型定義とは

型定義とは、どんなデータを受け取るかの設計図です。

type UserData = {
  name: string;
  age: number;
};

この設計図は「nameは文字列、ageは数値を受け取る」という約束です。

// OK
const user: UserData = {
  name: "田中",
  age: 25
};

// エラー! ageが文字列になっている
const user2: UserData = {
  name: "佐藤",
  age: "25才"
};

この「型定義」を理解すると、これから読むコードが理解できます。

コード全体

import { ButtonHTMLAttributes, ReactNode } from 'react'

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode
  variant?: 'primary' | 'secondary' | 'danger'
  isLoading?: boolean
}

export function Button({
  children,
  variant = 'primary',
  isLoading = false,
  className = '',
  disabled,
  ...props
}: ButtonProps) {
  const baseClasses = 'px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
  
  const variantClasses = {
    primary: 'bg-blue-500 text-white hover:bg-blue-600',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
    danger: 'bg-red-500 text-white hover:bg-red-600',
  }

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]} ${className}`}
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading ? '読み込み中...' : children}
    </button>
  )
}

1行目: import文

import { ButtonHTMLAttributes, ReactNode } from 'react'

何をしているか

Reactから2つの型定義をインポートしています。

ButtonHTMLAttributes<HTMLButtonElement>とは

HTMLの<button>タグが持つすべての属性の型定義です。

具体的には:

  • onClick: クリック時の処理を書く関数
  • disabled: ボタンを無効化するか(true/false)
  • type: ボタンのタイプ("button", "submit", "reset")
  • className: CSSのクラス名(文字列)
  • id, style, aria-*など、100以上の属性

つまり、普通のHTMLボタンに書けることすべてが含まれています。

ReactNodeとは

Reactで表示できるすべてのものを表す型です。

具体例:

  • 文字列: "クリック"
  • 数値: 123
  • JSX要素: <span>アイコン</span>
  • 配列: [<Icon />, "送信"]
  • null, undefined(画面には表示されない)

要するに、ボタンの中に入れられるものすべてを表す型です。

なぜこの2つをインポートするのか

  • ButtonHTMLAttributes: 通常のボタンが持つ機能を全部使えるようにする
  • ReactNode: ボタンの中身(children)に何でも入れられるようにする

3-7行目: ButtonPropsとは何か

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode
  variant?: 'primary' | 'secondary' | 'danger'
  isLoading?: boolean
}

ButtonPropsの役割

ButtonPropsは、このボタンコンポーネントが受け取れるデータの設計図です。

まだピンと来ないと思うので、例で説明します。

設計図がない場合

// どんなデータを渡せばいいか分からない
<Button ???>

設計図がある場合

// ButtonPropsという設計図があるので、何を渡せばいいか分かる
<Button variant="primary" isLoading={false}>
  送信
</Button>

ButtonPropsが「このボタンにはvariantisLoadingが渡せます」と教えてくれます。

3行目: interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>

interface ButtonProps

設計図の名前です。「ButtonPropsという名前の設計図を作る」という宣言です。

extends ButtonHTMLAttributes<HTMLButtonElement>

extendsは「継承する」という意味です。既にある設計図を引き継ぐことです。

図で表すと:

ButtonHTMLAttributes (既存の設計図)
├─ onClick
├─ disabled
├─ className
└─ その他100以上の属性

↓ extends (引き継ぐ)

ButtonProps (新しい設計図)
├─ onClick          ← ButtonHTMLAttributesから
├─ disabled         ← ButtonHTMLAttributesから
├─ className        ← ButtonHTMLAttributesから
├─ children         ← 新しく追加
├─ variant          ← 新しく追加
└─ isLoading        ← 新しく追加

つまり:

  • 既存のボタンの機能(onClick, disabledなど)は全部使える
  • さらに独自の機能(variant, isLoadingなど)も追加する

具体例:

// ButtonHTMLAttributesから使える機能
<Button onClick={() => alert('clicked')}>送信</Button>
<Button disabled={true}>送信</Button>
<Button className="mt-4">送信</Button>

// ButtonPropsで新しく追加した機能
<Button variant="primary">送信</Button>
<Button isLoading={true}>送信</Button>

// 全部組み合わせられる
<Button 
  variant="danger" 
  isLoading={false}
  onClick={() => alert('削除します')}
  className="w-full"
>
  削除
</Button>

4行目: children: ReactNode

ボタンの中身を受け取る型定義です。

<Button>送信</Button>  
// children = "送信" (文字列)

<Button><Icon />送信</Button>  
// children = [<Icon />, "送信"] (配列)

ReactNode型なので、文字列でもJSXでも何でも入ります。

5行目: variant?: 'primary' | 'secondary' | 'danger'

variantの意味

variantは「変化形」「バリエーション」という意味です。

このボタンコンポーネントでは、ボタンの見た目のバリエーションを指定します。

  • primary: メインのボタン(青色)
  • secondary: サブのボタン(グレー)
  • danger: 危険な操作のボタン(赤色)

?の意味

省略可能という意味です。variantを渡さなくてもエラーになりません。

<Button>送信</Button>  // variantなし → OK
<Button variant="primary">送信</Button>  // variantあり → OK

'primary' | 'secondary' | 'danger'の意味

この3つの文字列だけを受け付けます。

<Button variant="primary">   // OK
<Button variant="secondary"> // OK
<Button variant="danger">    // OK
<Button variant="warning">   // エラー! 'warning'は設計図にない
<Button variant="成功">      // エラー! 日本語は設計図にない

なぜこう定義するのか

ボタンの見た目を3種類に限定するためです。

もし何でも受け付けると:

variant?: string  // 何でもOK

<Button variant="あいうえお">  // 変なデータが入る

こうなると、対応する色が定義されていないのでエラーになります。

だから、使える値を限定する必要があります。

6行目: isLoading?: boolean

ローディング中かどうかのフラグです。

  • true: ローディング中
  • false: 通常

?があるので省略可能です。

<Button isLoading={true}>   // ローディング中
<Button isLoading={false}>  // 通常
<Button>                    // 省略(デフォルトでfalseになる)

ButtonPropsとfunction Buttonの違い

ここまで読んで、こう思ったはずです。

ButtonPropsfunction Buttonは何が違うの?」

ButtonPropsの役割

設計図です。「どんなデータを受け取れるか」を定義します。

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  children: ReactNode
  variant?: 'primary' | 'secondary' | 'danger'
  isLoading?: boolean
}

これは「受け取れるデータの種類」を書いただけです。実際の処理は何も書いていません。

function Buttonの役割

実際の処理を書く場所です。受け取ったデータを使って、ボタンを作ります。

export function Button({ children, variant, ... }: ButtonProps) {
  // 受け取ったデータを使って、実際にボタンを作る処理
  return <button>...</button>
}

図で表すと

ButtonProps (設計図)
↓
「variantは'primary' | 'secondary' | 'danger'だけ受け取れます」「isLoadingはboolean型です」

↓ 設計図を使って

function Button (実際の処理)
↓
「受け取ったvariantに応じて、色を変えます」「isLoadingがtrueなら、ボタンを無効化します」

例え話

ButtonProps = 料理のレシピ

  • 材料: 鶏肉、玉ねぎ、カレールー
  • 分量: 鶏肉300g、玉ねぎ1個

function Button = 実際の調理

  • 材料を切る
  • 炒める
  • 煮込む
  • 完成

レシピがあっても、調理しないと料理はできません。同様に、ButtonPropsがあっても、function Buttonがないとボタンは作れません。

9-16行目: 関数定義とpropsの受け取り

export function Button({
  children,
  variant = 'primary',
  isLoading = false,
  className = '',
  disabled,
  ...props
}: ButtonProps) {

9行目: export function Button(

Buttonという名前の関数(コンポーネント)を作り、外部から使えるようにします。

10-16行目: データの受け取り方

{
  children,
  variant = 'primary',
  isLoading = false,
  className = '',
  disabled,
  ...props
}: ButtonProps

末尾の: ButtonProps

「この関数はButtonPropsという設計図に従ってデータを受け取る」という宣言です。

つまり、ButtonPropsで定義した型以外は受け取れません。

// OK: ButtonPropsに定義されている
<Button variant="primary">送信</Button>

// エラー: ButtonPropsに定義されていない
<Button color="red">送信</Button>

{ children, variant, ... }の意味(分割代入)

propsオブジェクトから、必要な値を取り出す書き方です。

分割代入を使わない場合:

function Button(props: ButtonProps) {
  const children = props.children
  const variant = props.variant
  const isLoading = props.isLoading
  // 毎回props.○○と書く必要がある
}

分割代入を使う場合:

function Button({ children, variant, isLoading }: ButtonProps) {
  // そのまま使える
  console.log(children)
  console.log(variant)
}

デフォルト値の設定

variant = 'primary'は、variantが渡されなかった時の初期値です。

<Button>送信</Button>
// variantを渡していない
// → 自動的に variant = 'primary' になる

<Button variant="danger">削除</Button>
// variantを渡している
// → variant = 'danger' が使われる

同様に:

  • isLoading = false: 省略時はfalse
  • className = '': 省略時は空文字

disabledにデフォルト値がない理由

disabledは元々のHTML仕様で省略可能だからです。

<!-- disabled属性がない = 有効 -->
<button>クリック</button>

<!-- disabled属性がある = 無効 -->
<button disabled>クリック</button>

だから、デフォルト値を設定する必要がありません。

...propsの意味(スプレッド構文)

残りのpropsをすべて受け取るという意味です。

具体例:

<Button
  variant="primary"
  onClick={() => alert('click')}
  id="submit"
  aria-label="送信ボタン"
>
  送信
</Button>

この場合、データは以下のように分かれます:

// 個別に受け取るもの
variant = "primary"

// ...propsに入るもの
props = {
  onClick: () => alert('click'),
  id: "submit",
  "aria-label": "送信ボタン"
}

なぜこうするのか?

すべてのpropsを個別に書くと大変だからです。

// これは大変
function Button({
  children,
  variant,
  isLoading,
  className,
  disabled,
  onClick,
  onMouseEnter,
  onMouseLeave,
  id,
  style,
  // ... 100個以上書く必要がある
}: ButtonProps) {

...propsを使えば、残りは一括で受け取れます。

17行目: 基本スタイルの定義

const baseClasses = 'px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed'

すべてのボタンに共通するCSSクラス(見た目の設定)です。

  • px-4 py-2: 内側の余白(左右4、上下2)
  • rounded-lg: 角を丸くする
  • font-medium: 文字の太さを中間に
  • transition-colors: 色が変わる時にアニメーション
  • disabled:opacity-50: 無効化時に半透明にする
  • disabled:cursor-not-allowed: 無効化時にカーソルを禁止マークに

これはvariantに関係なく、すべてのボタンに適用されます。

19-23行目: バリエーション別スタイル

const variantClasses = {
  primary: 'bg-blue-500 text-white hover:bg-blue-600',
  secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
  danger: 'bg-red-500 text-white hover:bg-red-600',
}

これは何をしているのか

渡されたvariantによって、ボタンの色を変える設定です。

  • primary: 青いボタン
  • secondary: グレーのボタン
  • danger: 赤いボタン

オブジェクト形式で定義する理由

variantClasses[variant]

で、動的に取り出せるからです。

動作例:

variant = 'primary'
variantClasses[variant]  
// → 'bg-blue-500 text-white hover:bg-blue-600'

variant = 'danger'
variantClasses[variant]  
// → 'bg-red-500 text-white hover:bg-red-600'

各クラスの意味

primary(青いボタン):

  • bg-blue-500: 背景色を青に
  • text-white: 文字色を白に
  • hover:bg-blue-600: マウスを乗せたら濃い青に

secondary(グレーのボタン):

  • bg-gray-200: 背景色を薄いグレーに
  • text-gray-800: 文字色を濃いグレーに
  • hover:bg-gray-300: マウスを乗せたら少し濃いグレーに

danger(赤いボタン):

  • bg-red-500: 背景色を赤に
  • text-white: 文字色を白に
  • hover:bg-red-600: マウスを乗せたら濃い赤に

まとめると

このコードは、propsで渡されたvariantの値に応じて、ボタンの見た目を変えているということです。

<Button variant="primary">送信</Button>   // 青いボタン
<Button variant="secondary">キャンセル</Button>  // グレーのボタン
<Button variant="danger">削除</Button>     // 赤いボタン

25-33行目: JSXの返却

return (
  <button
    className={`${baseClasses} ${variantClasses[variant]} ${className}`}
    disabled={disabled || isLoading}
    {...props}
  >
    {isLoading ? '読み込み中...' : children}
  </button>
)

27行目: classNameの結合

className={`${baseClasses} ${variantClasses[variant]} ${className}`}

テンプレートリテラル(バッククォート)

`文字列 ${変数} 文字列`の形式です。変数を文字列の中に埋め込めます。

展開例:

variant = 'primary'
className = 'mt-4'

// 結果
"px-4 py-2 rounded-lg ... bg-blue-500 text-white hover:bg-blue-600 mt-4"

3つのクラスが結合されます:

  1. baseClasses: 共通スタイル
  2. variantClasses[variant]: バリエーション別スタイル
  3. className: 外部から渡されたカスタムスタイル

28行目: disabledの条件

disabled={disabled || isLoading}

||の意味

「または」という意味です。

  • disabledがtrue または isLoadingがtrue → ボタンを無効化
  • 両方false → ボタンは有効

具体例:

disabled = true, isLoading = false
→ true || false = true (無効化)

disabled = false, isLoading = true
→ false || true = true (無効化)

disabled = false, isLoading = false
→ false || false = false (有効)

disabled = true, isLoading = true
→ true || true = true (無効化)

つまり、どちらか一方でもtrueなら無効化するという意味です。

29行目: propsの展開

{...props}

残りのpropsをすべてボタンに渡します。

展開例:

props = {
  onClick: () => alert('click'),
  id: "submit",
  type: "button"
}

// 展開後
<button
  onClick={() => alert('click')}
  id="submit"
  type="button"
>

31行目: 条件分岐

{isLoading ? '読み込み中...' : children}

三項演算子

条件 ? 真の時 : 偽の時の形式です。

  • isLoadingがtrue → 「読み込み中...」と表示
  • isLoadingがfalse → childrenを表示

具体例:

// ローディング中
<Button isLoading={true}>送信</Button>
// 表示: 「読み込み中...」

// 通常時
<Button isLoading={false}>送信</Button>
// 表示: 「送信」

全体の流れ

1. ボタンを呼び出す

<Button variant="danger" isLoading={false} onClick={() => {}}>
  削除
</Button>

2. propsが渡される

children = "削除"
variant = "danger"
isLoading = false
props = { onClick: () => {} }

3. スタイルが組み立てられる

baseClasses = "px-4 py-2 ..."
variantClasses[variant] = "bg-red-500 text-white hover:bg-red-600"
className = ""

// 結合
className = "px-4 py-2 ... bg-red-500 text-white hover:bg-red-600"

4. disabledが判定される

disabled = false || false = false
// ボタンは有効

5. 表示内容が決まる

isLoading = false
// childrenを表示 → "削除"

6. 最終的なHTML

<button
  class="px-4 py-2 rounded-lg ... bg-red-500 text-white hover:bg-red-600"
  onClick={() => {}}
>
  削除
</button>

このコンポーネントの使い方

基本的な使い方

<Button>送信</Button>

結果:

  • 青いボタン(variant="primary"がデフォルト)
  • テキストは「送信」
  • クリック可能

バリエーションを変える

<Button variant="secondary">キャンセル</Button>
<Button variant="danger">削除</Button>

ローディング状態

<Button isLoading={true}>送信中</Button>

結果:

  • ボタンは無効化される
  • 表示は「読み込み中...」になる
  • 元の「送信中」というテキストは表示されない

カスタムスタイルを追加

<Button className="w-full mt-4">送信</Button>

結果:

  • 基本スタイル + バリエーションスタイル + w-full mt-4
  • 幅100%、上部に余白

HTML属性を渡す

<Button
  onClick={() => alert('clicked')}
  type="submit"
  id="submit-btn"
>
  送信
</Button>

すべて{...props}によってボタンに渡されます。

よくある疑問

疑問1: なぜextendsを使うのか

ButtonHTMLAttributesには100以上の属性があります。これを手動で全部書くのは不可能です。

extendsを使えば、自動的にすべて使えるようになります。

疑問2: ...propsは何のためか

予想できないpropsを受け取るためです。

<Button data-testid="submit-button">

data-testidは事前に型定義していませんが、...propsで受け取れます。

疑問3: なぜvariantにデフォルト値が必要か

省略時の動作を明確にするためです。

デフォルト値がないと:

variant: 'primary' | 'secondary' | 'danger' | undefined

undefinedの時の処理が必要になります。

デフォルト値があれば:

variant = 'primary'  // 必ず値が入っている

まとめ

このボタンコンポーネントは、以下の技術を組み合わせています。

  1. 型の継承: extends ButtonHTMLAttributesで既存の型を引き継ぐ
  2. デフォルト値: variant = 'primary'で省略時の値を設定
  3. スプレッド構文: {...props}で残りのpropsを一括受け取り
  4. 条件分岐: isLoading ? '読み込み中...' : childrenで表示を切り替え
  5. 動的スタイル: variantClasses[variant]でpropsに応じてスタイルを変更

これらを理解すれば、実務レベルのコンポーネントが書けるようになります。

次にコンポーネントを作る時は、このパターンを思い出してください。


参考資料