React.ComponentProps 型を積極的に使おう
Atomic Design でいう Atoms に相当する、汎用コンポーネントについての小話です。次の様に Props 型定義を用意し、解説している記事をよく見かけます。<input />
タグを使わずコンポーネント化している理由は style を施すためかと思いますが、このコンポーネントが受け取れる Props は限定的で、メンテナンスコストが高いためお勧めできません。
type Props = {
value: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>
onBlur?: React.FocusEventHandler<HTMLInputElement>
}
export const Input = ({ value, onChange, onBlur }: Props) => (
<input
value={value}
onChange={onChange}
onBlur={onBlur}
className={styles.input}
/>
)
メンテナンスコストの高い Atoms とは?
メンテナンスコストとは、例えばonFocus
を新たに付与したくなった時など、新しいユースケースに対応する修正コストを指します。Input
コンポーネントの Props にonFocus
を指定できるよう、以下の様に修正を加える必要があります。
<Input
value={value}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus} // <- New
/>
type Props = {
value: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>
onBlur?: React.FocusEventHandler<HTMLInputElement>
onFocus?: React.FocusEventHandler<HTMLInputElement> // <- New
}
export const Input = ({ value, onChange, onBlur, onFocus }: Props) => (
<input
value={value}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus} // <- New
className={styles.input}
/>
)
Atoms は汎用的なコンポーネント群であり、様々なユースケースに応えられる必要があります。例えばdata-testid
やaria
属性などが将来必要になることが予想されます。このコンポーネントに期待するのは、本来の<input />
タグに style を施すことだけです。
React.ComponentProps 型を使う
この様なシーンでは@types/react
にComponentProps
という型定義がありますので、これを使いましょう。Generics に文字列リテラルでタグ名指定をすれば、受け取りうる全ての Props 型を拾うことができます。「本来のタグとして振る舞ってほしいコンポーネント」の場合、Props の型定義は頑張ってはいけません。
type Props = React.ComponentProps<'input'> // <- here
export const Input = ({ value, onChange, onBlur, onFocus }: Props) => (
<input
value={value}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
className={styles.input}
/>
)
分割代入を使う
props をひとつひとつマッピングしていては、メンテナンスコストにさほど違いはありません。<input />
タグが受け取りうる全ての props は型指定できているため、あとは分割代入 (Destructuring assignment) で全て渡してしまいます。このとき、分割代入にはclassName
も含まれますが、後続でclassName={styles.input}
も指定しているため、分割代入で渡したclassName
は無効になります。
type Props = React.ComponentProps<'input'>
export const Input = (props: Props) => (
<input
{...props} // <- props を全て渡す(onFocus や className も含まれる)
className={styles.input} // <- こちらの指定が後勝ちになる
/>
)
props で受け取った className と、コンポーネントに施しておく className を共存させたい場合、clsxなどを利用して、スタイルを合成することができます。これで、ちょっとした style 修正の余地を残すことができます。
type Props = React.ComponentProps<'input'>
export const Input = ({ className, ...props }: Props) => (
<input
{...props} // <- className 以外、全ての props を分割代入
className={clsx(className, styles.input)}
/>
)
一部の Props だけ除去したい場合
逆に「className を受け取りたくない・微修正を許容したくない」という設計思想もあるかと思います。そういった設計の場合、TypeScript から標準で提供されているOmit
型を使いましょう。Omit<T, 'className'>
の様な指定で「T」に定義されている一部のプロパティを削除できます。これで、<Input />
コンポーネントは親からclassName
指定されることは無くなりました。
type Props = Omit<React.ComponentProps<'input'>, 'className'>
export const Input = (props: Props) => (
<input
{...props} // <- className は含まれない(型エラーで事前に弾かれる)
className={styles.input}
/>
)
「親から className は指定できるものの、適用されない」という実装を放置しておくと、後から使う人は混乱するかもしれません。受け取り拒否を明示的にするため、この様にしておく方が親切でしょう。
Ref の forwarding
この議題で厄介なものが、ref の受け渡しです。Function Component として定義したコンポーネントは、ref
という名称で props 渡しすることができません(別名であれば可能)。react-hook-form などが採用されている場合、ref を渡せなければ使い物になりません。これは次の通り Ref の forwarding を施しておけば OK です(Generics のHTMLInputElement
は適宜書き換えてください)
type Props = React.ComponentProps<'input'>
export const Input = React.forwardRef<HTMLInputElement, Props>(
({ className, ...props }, ref) => (
<input {...props} ref={ref} className={styles.input} />
)
);
以上のテクニックで、<input />
タグ本来の Props を全て受け継ぐことができ、<Input />
コンポーネントに任意のスタイルを適用することができました。他にも、React.ComponentProps<typeof Foo>
という指定で、Foo
コンポーネントに定められた Props をそのまま拝借することもできます。便利な型ですので、コンポーネント設計の際に活用してみてください。
Discussion
こんにちは。解説記事の執筆ありがとうございます。
ComponentProps
型を使うと誤ってref
が渡されたときに検知できないので、ComponentPropsWithoutRef
型を使ったほうがよいと思います。逆に
ref
が渡されてもよいときは、そのことを明示するためComponentPropsWithRef
型を使うのがよいかと思います。参考:
ご指摘ありがとうございます。異論なく、その方が良いと思います。