ReactのComponentPropsで、HTML要素と同じpropsを受け取る方法

​​React​ComponentProps,props,TypeScript

自作の Button に、<button> と同じ props(onClick、disabled、aria-label など)をすべて受け取らせたいときは、props の型を ComponentProps<'button'> から作ります。1つずつ型に書き足さなくても、<button> に渡せる props がそのまま使えるようになり、<button> にない props を渡すと型チェックで止まります。

筆者は検証環境で、ComponentProps を使った Button、TextField、IconButton を作り、型チェックの結果と、出力される HTML、Chromium での動きを確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。型チェックは tsc、HTML の確認は react-dom/server の renderToStaticMarkup、ブラウザでの確認は、esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作して行いました。

ComponentProps<'button'> に、自分の props を足す

次のコードは、見た目の種類を選べる variant を足した Button です。

tsx
import type { ComponentProps } from 'react';

type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'danger';
};

export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) {
  const classes = ['btn', `btn-${variant}`, className].filter(Boolean).join(' ');
  return <button type={type} className={classes} {...rest} />;
}

ComponentProps は、@types/react にある型です。'button' のように HTML の要素の名前を渡すと、その要素が JSX で受け取る props の型になります。@types/react 19.3.0 の定義では、要素の名前を渡したときは JSX.IntrinsicElements[T] を返します。TypeScript の JSX の型チェックの説明にあるとおり、<button> のような小文字の要素の props は、この JSX.IntrinsicElements から調べられます。つまり、<button> を直接書いたときに型チェックで使われるのと同じ型です。

ts
type ComponentProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = T extends
    JSXElementConstructor<infer Props> ? Props
    : T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T]
    : {};

Button の中では、自分で使う variant、type、className を取り出し、残り(rest)を JSX のスプレッド構文で <button> にまとめて渡しています。

使う側では、<button> と同じ props をそのまま書けます。

tsx
<Button id="count" onClick={() => setCount(count + 1)} aria-label="数える" data-testid="count">
  押した回数:{count}
</Button>
<Button variant="danger" className="wide" disabled>
  削除
</Button>

検証環境で tsc を通すと、エラーは出ませんでした。この部分を renderToStaticMarkup で HTML にすると、次のようになりました。id、aria-label、data-testid、disabled が、<button> の属性として出ています。

html
<button type="button" class="btn btn-primary" id="count" aria-label="数える" data-testid="count">押した回数:0</button>
<button type="button" class="btn btn-danger wide" disabled="">削除</button>

button にない props は、型チェックで止まる

わざと間違えた使い方を書いて、tsc を実行しました。

tsx
<Button href="/about">リンクのつもり</Button>
<Button type="link">種類の間違い</Button>
<Button variant="ghost">ない見た目</Button>

3行とも、型エラーになりました(1つ目は、エラーの続きの説明を省いています)。

text
errors/WrongUsage.tsx(7,15): error TS2322: Type '{ href: string; children: string; }' is not assignable to type 'IntrinsicAttributes & ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & { ...; }'.
errors/WrongUsage.tsx(8,15): error TS2322: Type '"link"' is not assignable to type '"button" | "reset" | "submit" | undefined'.
errors/WrongUsage.tsx(9,15): error TS2322: Type '"ghost"' is not assignable to type '"danger" | "primary" | undefined'.

href は <button> にない属性なので、受け取れません。type には、<button> と同じく "button"、"reset"、"submit" だけが渡せます。自分で足した variant も、決めた2つの値だけが通ります。

自分で決めたい props は、取り出してから残りを渡す

Button で className と type を取り出しているのには、理由があります。取り出さずに {...rest} を後ろに書くと、使う側が渡した値で、部品の中の値が上書きされるからです。

次の NaiveButton は、variant だけを取り出し、type と className を {...rest} の前に書いたものです。props の型(NaiveButtonProps)は、Button と同じく ComponentProps<'button'> & { variant?: 'primary' | 'danger' } です。

tsx
export function NaiveButton({ variant = 'primary', ...rest }: NaiveButtonProps) {
  return <button type="button" className={`btn btn-${variant}`} {...rest} />;
}

Button と NaiveButton に className="wide" を渡したときと、NaiveButton に type={undefined} を渡したときの HTML は、次のとおりでした。

html
<button type="button" class="btn btn-primary wide">保存</button>
<button type="button" class="wide">保存</button>
<button class="btn btn-primary">保存</button>

NaiveButton では、className="wide" が btn btn-primary を上書きしました。type={undefined} を渡すと、type="button" も消えました。MDN の button の説明にあるとおり、フォームの中の <button> は、type がないと送信のボタンになります。そのため、押しただけでフォームが送信されてしまいます。

Button のように取り出しておけば、className は部品のクラスとつなげられます。type は、props の既定値が undefined を渡されたときにも使われるので、type={undefined} でも "button" になります。

型を変えたい props は、Omit で外してから足す

入力欄の部品では、onChange で入力された文字列だけを受け取りたいことがあります。<input> の onChange はイベントを受け取る型です。そこに & で別の型を足すと、onChange は両方の型を同時に満たさなければならなくなります。

tsx
// Omit を使わずに、onChange の型だけを変えようとした場合
type TextFieldProps = ComponentProps<'input'> & {
  label: string;
  onChange?: (value: string) => void;
};

この型の TextField に onChange={(value: string) => console.log(value)} を渡すと、型エラーになりました。

text
errors/NoOmit.tsx(19,32): error TS2322: Type '(value: string) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement> & ((value: string) => void)'.
  Type '(value: string) => void' is not assignable to type 'ChangeEventHandler<HTMLInputElement, HTMLInputElement>'.
    Types of parameters 'value' and 'event' are incompatible.
      Type 'ChangeEvent<HTMLInputElement, HTMLInputElement>' is not assignable to type 'string'.

型を変えたい props は、TypeScript の Omit で先に外してから、新しい型を足します。

tsx
import type { ComponentProps } from 'react';

type TextFieldProps = Omit<ComponentProps<'input'>, 'onChange'> & {
  label: string;
  onChange?: (value: string) => void;
};

export function TextField({ label, onChange, ...rest }: TextFieldProps) {
  return (
    <label>
      {label}
      <input {...rest} onChange={(e) => onChange?.(e.target.value)} />
    </label>
  );
}

こうすると、使う側は onChange={setName} のように、文字列を受け取る関数を渡せます。name、placeholder、maxLength など、<input> のほかの props はそのまま使えます。反対に、onChange={(e) => console.log(e.target.value)} のようにイベントを受け取るつもりで書くと、Property 'target' does not exist on type 'string'. という型エラーになりました。

ほかのコンポーネントと同じ props を受け取る

ComponentProps には、要素の名前の代わりにコンポーネントの型(typeof Button)も渡せます。次の IconButton は、Button が受け取る props に icon を足して、残りを Button に渡します。

tsx
import type { ComponentProps } from 'react';
import { Button } from './Button';

type IconButtonProps = ComponentProps<typeof Button> & {
  icon: string;
};

export function IconButton({ icon, children, ...rest }: IconButtonProps) {
  return (
    <Button {...rest}>
      <span aria-hidden="true">{icon}</span> {children}
    </Button>
  );
}

export type Variant = ComponentProps<typeof Button>['variant'];

Button の props の型を export していなくても、Button 本体から取り出せます。最後の行のように ['variant'] を付ければ、1つの props の型だけを取り出すこともできます。この使い方は、React TypeScript Cheatsheet の ComponentProps の説明にも載っています。

<IconButton icon="→" type="submit" variant="primary">送信</IconButton> は、tsc を通り、次の HTML になりました。

html
<button type="submit" class="btn btn-primary">
<span aria-hidden="true">→</span> 送信</button>

Chromium で動かした結果

ここまでの部品を1つの画面に置き、Chromium で操作しました。

text
# aria-label で探したボタンを2回押した後: 押した回数:2
# 入力欄に「たま」と入れた後: 入力:たま
# 削除ボタンの disabled: true
# 入力欄の maxLength: 10

aria-label で探した Button を押すと、onClick が呼ばれて数が増えました。TextField では、onChange に渡した setName が文字列を受け取り、入力した「たま」が下に表示されました。disabled と maxLength も、ブラウザの要素に届いています。

検証環境のChromiumで操作した後の画面。名前の入力欄に「たま」と入り、その下に「入力:たま」、続いて濃い水色の「押した回数:2」ボタン、押せない状態で薄いピンクの、幅の広い「削除」ボタン、矢印の付いた「送信」ボタンが並ぶ
ボタンを2回押し、入力欄に「たま」と入れた後の画面

ref も props に含まれる

React 19 では、関数コンポーネントが ref を普通の props として受け取れるようになりました。@types/react 19.3.0 の ComponentProps<'input'> にも、ref が入っています。React TypeScript Cheatsheet の説明では、スプレッドで渡す props から ref を外したいときは、ComponentPropsWithoutRef を使うとされています。

検証で、'ref' が型のキーに含まれるかを確かめたところ、ComponentProps<'input'> では含まれ、ComponentPropsWithoutRef<'input'> では含まれませんでした。同じ説明によれば、React 19 以降はたいてい ComponentProps で足ります。

ポイント

ComponentProps<'要素名'> に自分の props を & で足し、自分で使う props を取り出してから、残りを {...rest} で要素に渡します。型を変えたい props は、Omit で外してから足します。

HTML の要素と同じ props は、ComponentProps から作る

ComponentProps<'button'> や ComponentProps<'input'> を使うと、自作の部品が HTML の要素と同じ props を受け取れるようになり、要素にない props や間違った値は型チェックで止まります。検証では、aria-label や data-testid、disabled が、型に何も書き足さずに <button> まで届きました。className や type のように部品の中でも決めたい props は、取り出してから残りを渡すと、使う側の値で上書きされずにすみます。

​Posted by 86