2026年8月26日
[React] 再利用可能なUIコンポーネントの作り方
はじめに
突然ですがWebフロント開発をするときにみなさんはどのUIライブラリを使用していますか?
- shadcn/ui
- material UI
- Hero UI
上記の3つをほぼ使用されると思います。もちろん私はshadcn/ui君を愛用しています!! ソースコードを直接プロジェクトに追加する独特なアプローチを採用しているから自分好みでさらにカスタマイズができるのがいいんですけど... shadcnのUIコンポーネント達がどのようなロジックで実装されているのか私がまったく理解してなかったので、そちらのロジックを勉強したアウトプットとしてこの記事をつくりました
私自身の予想
再利用可能なUIコンポーネントはこのような感じで実装されてるだろうな~
interface ButtonProps {
children: ReactNode;
className: string;
onClick: () => void;
}
export function Button({ children, className, onClick }: ButtonProps) {
const style: string = `border bg-sky-500 px-6 py-3 text-white rounded-md shadow font-medium hover:bg-sky-600 transition-all duration-300 ${className}`
return (
<button className={style} onClick={onClick}>
{ children }
</button>
)
}
結果: だいたいは正解
Button.tsxのコードでも、最低限の再利用可能なUIコンポーネントとしての機能はあるんですけど、UIライブラリのコンポーネントのような柔軟性は欠けてます。
Button.tsxのコードでは以下のようなシーンでは少し不便です
タグの属性にclassName,onClick以外にもid,onSubmit等の属性を付与したいときにいちいち型定義をしなければなりません
-> 正直これめっちゃ面倒くさい
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode
}
export function Button({children, ...props }: ButtonProps) {
...
return (
<button className="..." {...props}>
{ children }
</button>
)
}
のようにスプレッド構文とButtonのもつ属性の型たちをButtonPropsに継承させることでいちいち属性の型を定義する必要がなくなります。
tailwind-mergeを使った賢いTailwindCSSの使い方
Button-02.tsxのコードにはまだ問題が残っています。 外からカスタムのclassNameを渡したとき、コンポーネント内部のデフォルトスタイルと競合してしまいます。たとえば背景色だけ変えたいのに、両方のクラスがHTMLに出力されてしまい、どちらの色が反映されるかがCSSの読み込み順次第で不定になります。 これを解決するのがtailwind-mergeです。
// ❌ 問題: className同士が競合する
// <button>側にすでに bg-sky-500 が当たっているのに
// 外からさらに bg-green-500 を渡すと...
<Button className="bg-green-500">送信</Button>
// Tailwindは両方のクラスをHTMLに出力してしまう
// → class="... bg-sky-500 bg-green-500"
// CSSの読み込み順でどちらが勝つか不定になり、意図した色にならない
// ✅ 解決: tailwind-merge を使う
import { twMerge } from "tailwind-merge"
export function Button({ children, className, ...props }: ButtonProps) {
const style = twMerge(
"bg-sky-500 px-6 py-3 text-white rounded-md", // デフォルトスタイル
className // 外から渡されたクラス
)
// twMerge が同じプロパティ(bg-*)の競合を検知し
// 後から渡された bg-green-500 を優先して bg-sky-500 を除去してくれる
// → class="px-6 py-3 text-white rounded-md bg-green-500"
return (
<button className={style} {...props}>
{children}
</button>
)
}
cva(class-variance-authority)とtwMergeの合成
cva(class-variance-authority)は、variantごとのTailwindクラスを一元管理するためのライブラリです。条件分岐によるクラスの出し分けをシンプルな定義で表現できます。
cvaとtwMergeを組み合わせることで、variantごとにスタイルを管理しつつ、外からのカスタムクラスも安全にマージできる柔軟なコンポーネントが実現できます。
// cva: variantごとのクラスを一元管理するユーティリティ
// 第一引数: すべてのvariantに共通するベーススタイル
// 第二引数: variantの定義とデフォルト値
const buttonVariants = cva("rounded-md font-medium text-white shadow", {
variants: {
variant: {
primary: "bg-sky-500 hover:bg-sky-600",
secondary: "bg-green-500 hover:bg-green-600",
danger: "bg-red-500 hover:bg-red-600",
},
size: {
sm: "text-sm px-4 py-2",
md: "text-base px-6 py-4",
lg: "text-xl px-8 py-10",
},
},
defaultVariants: {
variant: "primary", // variant未指定時のフォールバック
size: "md",
}
})
// VariantProps: cvaの定義から variant・size の型を自動生成してくれる
// ButtonHTMLAttributesと合わせて継承することで属性の型定義が一切不要になる
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
children: ReactNode
}
export function Button({ children, className, variant, size, ...props }: ButtonProps) {
return (
// twMerge: 外から渡されたclassNameとvariantのクラスが競合しても安全にマージ
<button className={twMerge(buttonVariants({ variant, size, className }))} {...props}>
{children}
</button>
)
}
使用例
<Button>送信</Button> // primary + md(デフォルト)
<Button variant="danger" size="sm">削除</Button> // danger + sm
<Button variant="secondary" className="w-full"> // secondaryベースに幅だけカスタム
登録する
</Button>
<Button variant="secondary" className="w-full">登録する</Button> //カスタムUI
使用例のUI

clsxとcnでさらに柔軟性のとれた洗練されたコンポーネントに
clsxについて
clsxは条件分岐やオブジェクト形式でクラスを柔軟に結合できる便利なライブラリです。twMergeと組み合わせることで、条件付きのクラス出し分けとTailwindの競合解消を同時に解決できる超有能ライブラリです。
// clsxで条件分岐を処理してからtwMergeで競合を解消
// ただしこの書き方だと呼び出すたびにtwMerge(clsx(...))と書く必要がある
<button className={twMerge(clsx(buttonVariants({ variant, size }), className, isLoading && "opacity-50 cursor-not-allowed"))} {...props}>
{children}
</button>
このコードだと、twMerge(clsx(...))の組み合わせが毎回必要になり、コードが冗長になってきます。そこでこの2つをまとめたcn関数をユーティリティとして切り出すのが実務での一般的なパターンです。cn関数はshadcn/ui本家でも採用されている設計で、プロジェクト全体で使い回せる汎用ユーティリティとして機能します。
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
のようにcn関数を実装することで...
// twMerge(clsx(...))がcn()1つで済むようになり、スッキリする
<button className={cn(buttonVariants({ variant, size }), className, isLoading && "opacity-50 cursor-not-allowed")} {...props}>
{children}
</button>
完成コード
import { cva, type VariantProps } from "class-variance-authority"
import type { ButtonHTMLAttributes, ReactNode } from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva("rounded-md font-medium text-white shadow", {
variants: {
variant: {
primary: "bg-sky-500 hover:bg-sky-600",
secondary: "bg-green-500 hover:bg-green-600",
danger: "bg-red-500 hover:bg-red-600",
},
size: {
sm: "text-sm px-4 py-2",
md: "text-base px-6 py-4",
lg: "text-xl px-8 py-10",
},
},
defaultVariants: {
variant: "primary",
size: "md",
}
})
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
children: ReactNode
isLoading?: boolean
}
export function Button({ children, className, variant, size, isLoading, ...props }: ButtonProps) {
return (
<button
className={cn(
buttonVariants({ variant, size }),
className,
isLoading && "opacity-50 cursor-not-allowed"
)}
disabled={isLoading}
{...props}
>
{children}
</button>
)
}
さいごに
先ほども、私が言った通りcn関数はshadcn/ui本家が採用している設計です。この記事の締めくくりとしてなぜその設計を採用しているのかについて深掘りをしていきます。
cn関数の採用理由について
shadcn/uiの設計思想として「ソースコードを直接プロジェクトに追加する」という独自の思想があります。
そのため他のnpm,pnpm,yarn等でインストールするUIライブラリと異なり、開発者が自由にカスタマイズできることがshadcn/uiの素晴らしいところです。
-> 外から渡されるクラスとも安全に共存できる柔軟性が求められる
そちらの要件をみたすのがまさにcn関数です