ブラウザのネイティブHTML要素を拡張して最大限のカスタマイズを可能にする。

再利用可能なコンポーネントを構築する際、適切な型付けは柔軟でカスタマイズ可能、かつ型安全なインターフェースを作るために不可欠です。コンポーネントの型に関する確立されたパターンに従うことで、強力で使いやすいコンポーネントを提供できます。

単一要素のラッピング

エクスポートされる各コンポーネントは理想的には単一のHTMLまたはJSX要素をラップするべきです。この原則は合成可能でカスタマイズしやすいコンポーネントを作るために基本となります。

コンポーネントが複数の要素をラップしていると、プロップの掘り下げ(prop drilling)や複雑なAPIなしに特定の部分をカスタマイズすることが難しくなります。次のアンチパターンを考えてください:

@/components/ui/card.tsx
const Card = ({ title, description, footer, ...props }) => (
  <div {...props}>
    <div className="card-header">
      <h2>{title}</h2>
      <p>{description}</p>
    </div>
    <div className="card-footer">
      {footer}
    </div>
  </div>
);

前述のコンポジションで説明したように、このアプローチは以下のような問題を引き起こします:

  • ヘッダーのスタイリングを追加のプロップなしでカスタマイズできない
  • タイトルや説明に使われるHTML要素を制御できない
  • 特定のDOM構造に強制される

代わりに、各レイヤーはそれ自体のコンポーネントにするべきです。これにより各レイヤーを独立してカスタマイズでき、タイトルや説明に使用する正確なHTML要素を制御できます。

このアプローチの利点は次のとおりです:

  • 最大限のカスタマイズ - ユーザーは各レイヤーを独立してスタイルや修正が可能
  • propの掘り下げが不要 - props はそれを必要とする要素に直接渡される
  • セマンティックなHTML - ユーザーは正確なDOM構造を確認・制御できる
  • アクセシビリティの向上 - ARIA属性やセマンティック要素を直接制御できる
  • 単純なメンタルモデル - 1つのコンポーネント = 1つの要素

HTML属性の拡張

各コンポーネントはラップする要素のネイティブHTML属性を拡張するべきです。これによりユーザーは基盤となるHTML要素を完全に制御できます。

基本パターン

export type CardRootProps = React.ComponentProps<'div'> & {
  // Add your custom props here
  variant?: 'default' | 'outlined';
};

export const CardRoot = ({ variant = 'default', ...props }: CardRootProps) => (
  <div {...props} />
);

一般的なHTML属性の型

ReactはすべてのHTML要素に対する型定義を提供しています。コンポーネントに適したものを使用してください:

// For div elements
type DivProps = React.ComponentProps<'div'>;

// For button elements
type ButtonProps = React.ComponentProps<'button'>;

// For input elements
type InputProps = React.ComponentProps<'input'>;

// For form elements
type FormProps = React.ComponentProps<'form'>;

// For anchor elements
type LinkProps = React.ComponentProps<'a'>;

異なる要素タイプの処理

コンポーネントが異なる要素としてレンダリングできる場合は、ジェネリクスやユニオン型を使用してください:

// Using discriminated unions
export type ButtonProps =
  | (React.ComponentProps<'button'> & { asChild?: false })
  | (React.ComponentProps<'div'> & { asChild: true });

// Or with a polymorphic approach
export type PolymorphicProps<T extends React.ElementType> = {
  as?: T;
} & React.ComponentPropsWithoutRef<T>;

カスタムコンポーネントの拡張

既存のコンポーネントを拡張する場合、ComponentProps 型を使用してコンポーネントの props を取得できます。

@/components/ui/share-button.tsx
import type { ComponentProps } from 'react';

export type ShareButtonProps = ComponentProps<'button'>;

export const ShareButton = (props: ShareButtonProps) => (
  <button {...props} />
);

型のエクスポート

常にコンポーネントの prop 型をエクスポートしてください。これにより利用者がさまざまなユースケースでそれらを利用できるようになります。

型をエクスポートすることでいくつかの重要なパターンが可能になります:

// 1. Extracting specific prop types
import type { CardRootProps } from '@/components/ui/card';
type variant = CardRootProps['variant'];

// 2. Extending components
export type ExtendedCardProps = CardRootProps & {
  isLoading?: boolean;
};

// 3. Creating wrapper components
const MyCard = (props: CardRootProps) => (
  <CardRoot {...props} className={cn('my-custom-class', props.className)} />
);

// 4. Type-safe prop forwarding
function useCardProps(): Partial<CardRootProps> {
  return {
    variant: 'outlined',
    className: 'custom-card',
  };
}

Your exported types should be named <ComponentName>Props. This is a convention that helps other developers understand the purpose of the type.

ベストプラクティス

1. 常に props を最後にスプレッドする

ユーザーがデフォルトの props を上書きできるようにしてください:

// ✅ Good - user props override defaults
<div className="default-class" {...props} />

// ❌ Bad - defaults override user props
<div {...props} className="default-class" />

2. prop名の競合を避ける

意図的にオーバーライドする場合を除き、HTML属性と競合するprop名は使用しないでください:

// ❌ Bad - conflicts with HTML title attribute
export type CardProps = React.ComponentProps<'div'> & {
  title: string; // This conflicts with the HTML title attribute
};

// ✅ Good - use a different name
export type CardProps = React.ComponentProps<'div'> & {
  heading: string;
};

3. カスタムpropsをドキュメント化する

カスタムpropsにはJSDocコメントを追加して、開発者体験を向上させてください:

export type DialogProps = React.ComponentProps<'div'> & {
  /** Whether the dialog is currently open */
  open: boolean;
  /** Callback when the dialog requests to be closed */
  onOpenChange: (open: boolean) => void;
  /** Whether to render the dialog in a portal */
  modal?: boolean;
};